diff --git a/.changeset/chatty-moons-hammer.md b/.changeset/chatty-moons-hammer.md new file mode 100644 index 0000000000..ea45fb1f80 --- /dev/null +++ b/.changeset/chatty-moons-hammer.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +make List.Cons extend NonEmptyIterable diff --git a/.changeset/clean-trainers-tap.md b/.changeset/clean-trainers-tap.md new file mode 100644 index 0000000000..cbabf51d39 --- /dev/null +++ b/.changeset/clean-trainers-tap.md @@ -0,0 +1,34 @@ +--- +"effect": minor +--- + +add DateTime module + +The `DateTime` module provides functionality for working with time, including +support for time zones and daylight saving time. + +It has two main data types: `DateTime.Utc` and `DateTime.Zoned`. + +A `DateTime.Utc` represents a time in Coordinated Universal Time (UTC), and +a `DateTime.Zoned` contains both a UTC timestamp and a time zone. + +There is also a `CurrentTimeZone` service, for setting a time zone contextually. + +```ts +import { DateTime, Effect } from "effect"; + +Effect.gen(function* () { + // Get the current time in the current time zone + const now = yield* DateTime.nowInCurrentZone; + + // Math functions are included + const tomorrow = DateTime.add(now, 1, "day"); + + // Convert to a different time zone + // The UTC portion of the `DateTime` is preserved and only the time zone is + // changed + const sydneyTime = tomorrow.pipe( + DateTime.unsafeSetZoneNamed("Australia/Sydney"), + ); +}).pipe(DateTime.withCurrentZoneNamed("America/New_York")); +``` diff --git a/.changeset/fluffy-countries-repair.md b/.changeset/fluffy-countries-repair.md new file mode 100644 index 0000000000..4326030724 --- /dev/null +++ b/.changeset/fluffy-countries-repair.md @@ -0,0 +1,5 @@ +--- +"@effect/sql-mssql": patch +--- + +make mssql placeholder format compatible with kysely diff --git a/.changeset/forty-beers-refuse.md b/.changeset/forty-beers-refuse.md new file mode 100644 index 0000000000..17c20ae72c --- /dev/null +++ b/.changeset/forty-beers-refuse.md @@ -0,0 +1,35 @@ +--- +"effect": minor +--- + +add Stream.asyncPush api + +This api creates a stream from an external push-based resource. + +You can use the `emit` helper to emit values to the stream. You can also use +the `emit` helper to signal the end of the stream by using apis such as +`emit.end` or `emit.fail`. + +By default it uses an "unbounded" buffer size. +You can customize the buffer size and strategy by passing an object as the +second argument with the `bufferSize` and `strategy` fields. + +```ts +import { Effect, Stream } from "effect"; + +Stream.asyncPush( + (emit) => + Effect.acquireRelease( + Effect.gen(function* () { + yield* Effect.log("subscribing"); + return setInterval(() => emit.single("tick"), 1000); + }), + (handle) => + Effect.gen(function* () { + yield* Effect.log("unsubscribing"); + clearInterval(handle); + }), + ), + { bufferSize: 16, strategy: "dropping" }, +); +``` diff --git a/.changeset/lovely-buckets-sip.md b/.changeset/lovely-buckets-sip.md new file mode 100644 index 0000000000..bd3a04fa54 --- /dev/null +++ b/.changeset/lovely-buckets-sip.md @@ -0,0 +1,19 @@ +--- +"effect": minor +--- + +Implement Struct.keys as a typed alternative to Object.keys + +```ts +import { Struct } from "effect" + +const symbol: unique symbol = Symbol() + +const value = { + a: 1, + b: 2, + [symbol]: 3 +} + +const keys: Array<"a" | "b"> = Struct.keys(value) +``` diff --git a/.changeset/metal-spoons-flash.md b/.changeset/metal-spoons-flash.md new file mode 100644 index 0000000000..9fc5829820 --- /dev/null +++ b/.changeset/metal-spoons-flash.md @@ -0,0 +1,5 @@ +--- +"@effect/sql-kysely": patch +--- + +Add kysely support with @effect/sql-kysely package diff --git a/.changeset/new-garlics-own.md b/.changeset/new-garlics-own.md new file mode 100644 index 0000000000..134ea792e3 --- /dev/null +++ b/.changeset/new-garlics-own.md @@ -0,0 +1,14 @@ +--- +"effect": minor +--- + +Add `Random.choice`. + +```ts +import { Random } from "effect" + +Effect.gen(function* () { + const randomItem = yield* Random.choice([1, 2, 3]) + console.log(randomItem) +}) +``` diff --git a/.changeset/pink-ghosts-suffer.md b/.changeset/pink-ghosts-suffer.md new file mode 100644 index 0000000000..f2eff72d0c --- /dev/null +++ b/.changeset/pink-ghosts-suffer.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add onlyEffect option to Effect.tap diff --git a/.changeset/purple-onions-drive.md b/.changeset/purple-onions-drive.md new file mode 100644 index 0000000000..7e948c5304 --- /dev/null +++ b/.changeset/purple-onions-drive.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Support `Refinement` in `Predicate.tuple` and `Predicate.struct` diff --git a/.changeset/red-bottles-cough.md b/.changeset/red-bottles-cough.md new file mode 100644 index 0000000000..cc82c9de7f --- /dev/null +++ b/.changeset/red-bottles-cough.md @@ -0,0 +1,5 @@ +--- +"@effect/schema": patch +--- + +add schemas for working with the DateTime module diff --git a/.changeset/stream-on-end.md b/.changeset/stream-on-end.md new file mode 100644 index 0000000000..1e9964d451 --- /dev/null +++ b/.changeset/stream-on-end.md @@ -0,0 +1,22 @@ +--- +"effect": minor +--- + +Implement `Stream.onEnd` that adds an effect to be executed at the end of the stream. + +```ts +import { Console, Effect, Stream } from "effect"; + +const stream = Stream.make(1, 2, 3).pipe( + Stream.map((n) => n * 2), + Stream.tap((n) => Console.log(`after mapping: ${n}`)), + Stream.onEnd(Console.log("Stream ended")) +) + +Effect.runPromise(Stream.runCollect(stream)).then(console.log) +// after mapping: 2 +// after mapping: 4 +// after mapping: 6 +// Stream ended +// { _id: 'Chunk', values: [ 2, 4, 6 ] } +``` \ No newline at end of file diff --git a/.changeset/stream-on-start.md b/.changeset/stream-on-start.md new file mode 100644 index 0000000000..ff07852d97 --- /dev/null +++ b/.changeset/stream-on-start.md @@ -0,0 +1,22 @@ +--- +"effect": minor +--- + +Implement `Stream.onStart` that adds an effect to be executed at the start of the stream. + +```ts +import { Console, Effect, Stream } from "effect"; + +const stream = Stream.make(1, 2, 3).pipe( + Stream.onStart(Console.log("Stream started")), + Stream.map((n) => n * 2), + Stream.tap((n) => Console.log(`after mapping: ${n}`)) +) + +Effect. runPromise(Stream. runCollect(stream)).then(console. log) +// Stream started +// after mapping: 2 +// after mapping: 4 +// after mapping: 6 +// { _id: 'Chunk', values: [ 2, 4, 6 ] } +``` diff --git a/.changeset/tricky-cheetahs-help.md b/.changeset/tricky-cheetahs-help.md new file mode 100644 index 0000000000..4667c66bea --- /dev/null +++ b/.changeset/tricky-cheetahs-help.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add `bufferSize` option to Stream.fromEventListener diff --git a/.changeset/wet-laws-smoke.md b/.changeset/wet-laws-smoke.md new file mode 100644 index 0000000000..2325a3248e --- /dev/null +++ b/.changeset/wet-laws-smoke.md @@ -0,0 +1,8 @@ +--- +"@effect/platform": patch +"effect": minor +"@effect/cli": patch +"@effect/rpc": patch +--- + +Changed various function signatures to return `Array` instead of `ReadonlyArray` diff --git a/packages/cli/src/internal/commandDescriptor.ts b/packages/cli/src/internal/commandDescriptor.ts index 00ed9ace1f..49c1366801 100644 --- a/packages/cli/src/internal/commandDescriptor.ts +++ b/packages/cli/src/internal/commandDescriptor.ts @@ -512,7 +512,7 @@ const parseInternal = ( case "Standard": { const parseCommandLine = ( args: ReadonlyArray - ): Effect.Effect, ValidationError.ValidationError> => + ): Effect.Effect, ValidationError.ValidationError> => Arr.matchLeft(args, { onEmpty: () => { const error = InternalHelpDoc.p(`Missing command name: '${self.name}'`) diff --git a/packages/effect/dtslint/Effect.ts b/packages/effect/dtslint/Effect.ts index 813fa1f7a4..2ca31e3124 100644 --- a/packages/effect/dtslint/Effect.ts +++ b/packages/effect/dtslint/Effect.ts @@ -462,6 +462,18 @@ Effect.succeed("a" as const).pipe(Effect.filterOrElse( // $ExpectType Effect<"a", never, never> Effect.succeed("a" as const).pipe(Effect.tap(tacitString)) +// $ExpectType Effect<"a", never, never> +Effect.succeed("a" as const).pipe(Effect.tap(tacitString, { onlyEffect: true })) + +// @ts-expect-error +Effect.succeed("a" as const).pipe(Effect.tap(tacitStringError, { onlyEffect: true })) + +// $ExpectType Effect<"a", never, never> +Effect.succeed("a" as const).pipe(Effect.tap(tacitString("a"), { onlyEffect: true })) + +// @ts-expect-error +Effect.succeed("a" as const).pipe(Effect.tap("a", { onlyEffect: true })) + // $ExpectType Effect Effect.fail("a" as const).pipe(Effect.tapError(tacitString)) diff --git a/packages/effect/dtslint/Predicate.ts b/packages/effect/dtslint/Predicate.ts index 4d8a9a12c3..d7d3a4db25 100644 --- a/packages/effect/dtslint/Predicate.ts +++ b/packages/effect/dtslint/Predicate.ts @@ -271,3 +271,51 @@ pipe(Predicate.isString, Predicate.or(Predicate.isNumber)) // $ExpectType Refinement Predicate.or(Predicate.isString, Predicate.isNumber) + +// ------------------------------------------------------------------------------------- +// tuple +// ------------------------------------------------------------------------------------- + +const isA = hole>() +const isTrue = hole>() +const isOdd = hole>() + +// $ExpectType Refinement +Predicate.tuple(isTrue, isA) + +// $ExpectType Refinement +Predicate.tuple(isTrue, isOdd) + +// $ExpectType Predicate +Predicate.tuple(isOdd, isOdd) + +// $ExpectType Predicate +Predicate.tuple(...hole>>()) + +// $ExpectType Refinement +Predicate.tuple(...hole | Predicate.Refinement>>()) + +// $ExpectType Refinement +Predicate.tuple(...hole>>()) + +// ------------------------------------------------------------------------------------- +// struct +// ------------------------------------------------------------------------------------- + +// $ExpectType Refinement<{ readonly a: string; readonly true: boolean; }, { readonly a: "a"; readonly true: true; }> +Predicate.struct({ + a: isA, + true: isTrue +}) + +// $ExpectType Refinement<{ readonly odd: number; readonly true: boolean; }, { readonly odd: number; readonly true: true; }> +Predicate.struct({ + odd: isOdd, + true: isTrue +}) + +// $ExpectType Predicate<{ readonly odd: number; readonly odd1: number; }> +Predicate.struct({ + odd: isOdd, + odd1: isOdd +}) diff --git a/packages/effect/dtslint/Random.ts b/packages/effect/dtslint/Random.ts new file mode 100644 index 0000000000..c236e97924 --- /dev/null +++ b/packages/effect/dtslint/Random.ts @@ -0,0 +1,30 @@ +import type * as Array from "../src/Array.js" +import type { Chunk } from "../src/index.js" +import * as Random from "../src/Random.js" + +declare const array: Array +declare const nonEmptyArray: Array.NonEmptyArray + +// $ExpectType Effect +Random.choice(array) + +// $ExpectType Effect +Random.choice(nonEmptyArray) + +declare const readonlyArray: Array +declare const nonEmptyReadonlyArray: Array.NonEmptyArray + +// $ExpectType Effect +Random.choice(readonlyArray) + +// $ExpectType Effect +Random.choice(nonEmptyReadonlyArray) + +declare const chunk: Chunk.Chunk +declare const nonEmptyChunk: Chunk.NonEmptyChunk + +// $ExpectType Effect +Random.choice(chunk) + +// $ExpectType Effect +Random.choice(nonEmptyChunk) diff --git a/packages/effect/src/ConfigProvider.ts b/packages/effect/src/ConfigProvider.ts index 57c11d8541..001d6ac570 100644 --- a/packages/effect/src/ConfigProvider.ts +++ b/packages/effect/src/ConfigProvider.ts @@ -81,7 +81,7 @@ export declare namespace ConfigProvider { path: ReadonlyArray, config: Config.Config.Primitive, split?: boolean - ): Effect.Effect, ConfigError.ConfigError> + ): Effect.Effect, ConfigError.ConfigError> enumerateChildren( path: ReadonlyArray ): Effect.Effect, ConfigError.ConfigError> @@ -162,7 +162,7 @@ export const makeFlat: (options: { path: ReadonlyArray, config: Config.Config.Primitive, split: boolean - ) => Effect.Effect, ConfigError.ConfigError> + ) => Effect.Effect, ConfigError.ConfigError> readonly enumerateChildren: ( path: ReadonlyArray ) => Effect.Effect, ConfigError.ConfigError> diff --git a/packages/effect/src/DateTime.ts b/packages/effect/src/DateTime.ts new file mode 100644 index 0000000000..5e1b6cc094 --- /dev/null +++ b/packages/effect/src/DateTime.ts @@ -0,0 +1,2104 @@ +/** + * @since 3.6.0 + */ +import { IllegalArgumentException } from "./Cause.js" +import * as Clock from "./Clock.js" +import * as Context from "./Context.js" +import * as Duration from "./Duration.js" +import * as Effect from "./Effect.js" +import * as Either from "./Either.js" +import * as Equal from "./Equal.js" +import * as Equivalence_ from "./Equivalence.js" +import type { LazyArg } from "./Function.js" +import { dual, pipe } from "./Function.js" +import { globalValue } from "./GlobalValue.js" +import * as Hash from "./Hash.js" +import * as Inspectable from "./Inspectable.js" +import * as Layer from "./Layer.js" +import * as Option from "./Option.js" +import * as order from "./Order.js" +import { type Pipeable, pipeArguments } from "./Pipeable.js" +import * as Predicate from "./Predicate.js" +import type { Mutable } from "./Types.js" + +/** + * @since 3.6.0 + * @category type ids + */ +export const TypeId: unique symbol = Symbol.for("effect/DateTime") + +/** + * @since 3.6.0 + * @category type ids + */ +export type TypeId = typeof TypeId + +/** + * A `DateTime` represents a point in time. It can optionally have a time zone + * associated with it. + * + * @since 3.6.0 + * @category models + */ +export type DateTime = Utc | Zoned + +/** + * @since 3.6.0 + * @category models + */ +export interface Utc extends DateTime.Proto { + readonly _tag: "Utc" + readonly epochMillis: number + /** @internal */ + partsUtc: DateTime.PartsWithWeekday +} + +/** + * @since 3.6.0 + * @category models + */ +export interface Zoned extends DateTime.Proto { + readonly _tag: "Zoned" + readonly epochMillis: number + readonly zone: TimeZone + /** @internal */ + adjustedEpochMillis?: number + /** @internal */ + partsAdjusted?: DateTime.PartsWithWeekday + /** @internal */ + partsUtc?: DateTime.PartsWithWeekday +} + +/** + * @since 3.6.0 + * @category models + */ +export declare namespace DateTime { + /** + * @since 3.6.0 + * @category models + */ + export type Input = DateTime | Partial | Date | number | string + + /** + * @since 3.6.0 + * @category models + */ + export type PreserveZone = A extends Zoned ? Zoned : Utc + + /** + * @since 3.6.0 + * @category models + */ + export type Unit = UnitSingular | UnitPlural + + /** + * @since 3.6.0 + * @category models + */ + export type UnitSingular = + | "milli" + | "second" + | "minute" + | "hour" + | "day" + | "week" + | "month" + | "year" + + /** + * @since 3.6.0 + * @category models + */ + export type UnitPlural = + | "millis" + | "seconds" + | "minutes" + | "hours" + | "days" + | "weeks" + | "months" + | "years" + + /** + * @since 3.6.0 + * @category models + */ + export interface PartsWithWeekday { + readonly millis: number + readonly seconds: number + readonly minutes: number + readonly hours: number + readonly day: number + readonly weekDay: number + readonly month: number + readonly year: number + } + + /** + * @since 3.6.0 + * @category models + */ + export interface Parts { + readonly millis: number + readonly seconds: number + readonly minutes: number + readonly hours: number + readonly day: number + readonly month: number + readonly year: number + } + + /** + * @since 3.6.0 + * @category models + */ + export interface PartsForMath { + readonly millis: number + readonly seconds: number + readonly minutes: number + readonly hours: number + readonly days: number + readonly weeks: number + readonly months: number + readonly years: number + } + + /** + * @since 3.6.0 + * @category models + */ + export interface Proto extends Pipeable, Inspectable.Inspectable { + readonly [TypeId]: TypeId + } +} + +/** + * @since 3.6.0 + * @category type ids + */ +export const TimeZoneTypeId: unique symbol = Symbol.for("effect/DateTime/TimeZone") + +/** + * @since 3.6.0 + * @category type ids + */ +export type TimeZoneTypeId = typeof TimeZoneTypeId + +/** + * @since 3.6.0 + * @category models + */ +export type TimeZone = TimeZone.Offset | TimeZone.Named + +/** + * @since 3.6.0 + * @category models + */ +export declare namespace TimeZone { + /** + * @since 3.6.0 + * @category models + */ + export interface Proto extends Inspectable.Inspectable { + readonly [TimeZoneTypeId]: TimeZoneTypeId + } + + /** + * @since 3.6.0 + * @category models + */ + export interface Offset extends Proto { + readonly _tag: "Offset" + readonly offset: number + } + + /** + * @since 3.6.0 + * @category models + */ + export interface Named extends Proto { + readonly _tag: "Named" + readonly id: string + /** @internal */ + readonly format: Intl.DateTimeFormat + } +} + +const Proto = { + [TypeId]: TypeId, + pipe() { + return pipeArguments(this, arguments) + }, + [Inspectable.NodeInspectSymbol](this: DateTime) { + return this.toString() + }, + toJSON(this: DateTime) { + return toDateUtc(this).toJSON() + } +} + +const ProtoUtc = { + ...Proto, + _tag: "Utc", + [Hash.symbol](this: Utc) { + return Hash.cached(this, Hash.number(this.epochMillis)) + }, + [Equal.symbol](this: Utc, that: unknown) { + return isDateTime(that) && that._tag === "Utc" && this.epochMillis === that.epochMillis + }, + toString(this: Utc) { + return `DateTime.Utc(${toDateUtc(this).toJSON()})` + } +} + +const ProtoZoned = { + ...Proto, + _tag: "Zoned", + [Hash.symbol](this: Zoned) { + return pipe( + Hash.number(this.epochMillis), + Hash.combine(Hash.hash(this.zone)), + Hash.cached(this) + ) + }, + [Equal.symbol](this: Zoned, that: unknown) { + return isDateTime(that) && that._tag === "Zoned" && this.epochMillis === that.epochMillis && + Equal.equals(this.zone, that.zone) + }, + toString(this: Zoned) { + return `DateTime.Zoned(${formatIsoZoned(this)})` + } +} + +const ProtoTimeZone = { + [TimeZoneTypeId]: TimeZoneTypeId, + [Inspectable.NodeInspectSymbol](this: TimeZone) { + return this.toString() + } +} + +const ProtoTimeZoneNamed = { + ...ProtoTimeZone, + _tag: "Named", + [Hash.symbol](this: TimeZone.Named) { + return Hash.cached(this, Hash.string(`Named:${this.id}`)) + }, + [Equal.symbol](this: TimeZone.Named, that: unknown) { + return isTimeZone(that) && that._tag === "Named" && this.id === that.id + }, + toString(this: TimeZone.Named) { + return `TimeZone.Named(${this.id})` + }, + toJSON(this: TimeZone.Named) { + return { + _id: "TimeZone", + _tag: "Named", + id: this.id + } + } +} + +const ProtoTimeZoneOffset = { + ...ProtoTimeZone, + _tag: "Offset", + [Hash.symbol](this: TimeZone.Offset) { + return Hash.cached(this, Hash.string(`Offset:${this.offset}`)) + }, + [Equal.symbol](this: TimeZone.Offset, that: unknown) { + return isTimeZone(that) && that._tag === "Offset" && this.offset === that.offset + }, + toString(this: TimeZone.Offset) { + return `TimeZone.Offset(${offsetToString(this.offset)})` + }, + toJSON(this: TimeZone.Offset) { + return { + _id: "TimeZone", + _tag: "Offset", + offset: this.offset + } + } +} + +const makeZonedProto = (epochMillis: number, zone: TimeZone, partsUtc?: DateTime.PartsWithWeekday): Zoned => { + const self = Object.create(ProtoZoned) + self.epochMillis = epochMillis + self.zone = zone + self.partsUtc = partsUtc + return self +} + +// ============================================================================= +// guards +// ============================================================================= + +/** + * @since 3.6.0 + * @category guards + */ +export const isDateTime = (u: unknown): u is DateTime => Predicate.hasProperty(u, TypeId) + +const isDateTimeArgs = (args: IArguments) => isDateTime(args[0]) + +/** + * @since 3.6.0 + * @category guards + */ +export const isTimeZone = (u: unknown): u is TimeZone => Predicate.hasProperty(u, TimeZoneTypeId) + +/** + * @since 3.6.0 + * @category guards + */ +export const isTimeZoneOffset = (u: unknown): u is TimeZone.Offset => isTimeZone(u) && u._tag === "Offset" + +/** + * @since 3.6.0 + * @category guards + */ +export const isTimeZoneNamed = (u: unknown): u is TimeZone.Named => isTimeZone(u) && u._tag === "Named" + +/** + * @since 3.6.0 + * @category guards + */ +export const isUtc = (self: DateTime): self is Utc => self._tag === "Utc" + +/** + * @since 3.6.0 + * @category guards + */ +export const isZoned = (self: DateTime): self is Zoned => self._tag === "Zoned" + +// ============================================================================= +// instances +// ============================================================================= + +/** + * @since 3.6.0 + * @category instances + */ +export const Equivalence: Equivalence_.Equivalence = Equivalence_.make((a, b) => + a.epochMillis === b.epochMillis +) + +/** + * @since 3.6.0 + * @category instances + */ +export const Order: order.Order = order.make((self, that) => + self.epochMillis < that.epochMillis ? -1 : self.epochMillis > that.epochMillis ? 1 : 0 +) + +/** + * @since 3.6.0 + */ +export const clamp: { + (options: { minimum: DateTime; maximum: DateTime }): (self: DateTime) => DateTime + (self: DateTime, options: { minimum: DateTime; maximum: DateTime }): DateTime +} = order.clamp(Order) + +// ============================================================================= +// constructors +// ============================================================================= + +const makeUtc = (epochMillis: number): Utc => { + const self = Object.create(ProtoUtc) + self.epochMillis = epochMillis + return self +} + +/** + * Create a `DateTime` from a `Date`. + * + * If the `Date` is invalid, an `IllegalArgumentException` will be thrown. + * + * @since 3.6.0 + * @category constructors + */ +export const unsafeFromDate = (date: Date): Utc => { + const epochMillis = date.getTime() + if (Number.isNaN(epochMillis)) { + throw new IllegalArgumentException("Invalid date") + } + return makeUtc(epochMillis) +} + +/** + * Create a `DateTime` from one of the following: + * + * - A `DateTime` + * - A `Date` instance (invalid dates will throw an `IllegalArgumentException`) + * - The `number` of milliseconds since the Unix epoch + * - An object with the parts of a date + * - A `string` that can be parsed by `Date.parse` + * + * @since 3.6.0 + * @category constructors + * @example + * import { DateTime } from "effect" + * + * // from Date + * DateTime.unsafeMake(new Date()) + * + * // from parts + * DateTime.unsafeMake({ year: 2024 }) + * + * // from string + * DateTime.unsafeMake("2024-01-01") + */ +export const unsafeMake = (input: A): DateTime.PreserveZone => { + if (isDateTime(input)) { + return input as DateTime.PreserveZone + } else if (input instanceof Date) { + return unsafeFromDate(input) as DateTime.PreserveZone + } else if (typeof input === "object") { + const date = new Date(0) + setPartsDate(date, input) + return unsafeFromDate(date) as DateTime.PreserveZone + } + return unsafeFromDate(new Date(input)) as DateTime.PreserveZone +} + +/** + * Create a `DateTime.Zoned` using `DateTime.unsafeMake` and a time zone. + * + * The input is treated as UTC and then the time zone is attached, unless + * `adjustForTimeZone` is set to `true`. In that case, the input is treated as + * already in the time zone. + * + * @since 3.6.0 + * @category constructors + * @example + * import { DateTime } from "effect" + * + * DateTime.unsafeMakeZoned(new Date(), { timeZone: "Europe/London" }) + */ +export const unsafeMakeZoned = (input: DateTime.Input, options: { + readonly timeZone: number | string | TimeZone + readonly adjustForTimeZone?: boolean | undefined +}): Zoned => { + const self = unsafeMake(input) + let zone: TimeZone + if (isTimeZone(options.timeZone)) { + zone = options.timeZone + } else if (typeof options.timeZone === "number") { + zone = zoneMakeOffset(options.timeZone) + } else { + const parsedZone = zoneFromString(options.timeZone) + if (Option.isNone(parsedZone)) { + throw new IllegalArgumentException(`Invalid time zone: ${options.timeZone}`) + } + zone = parsedZone.value + } + if (options.adjustForTimeZone !== true) { + return makeZonedProto(self.epochMillis, zone, self.partsUtc) + } + return makeZonedFromAdjusted(self.epochMillis, zone) +} + +/** + * Create a `DateTime.Zoned` using `DateTime.make` and a time zone. + * + * The input is treated as UTC and then the time zone is attached. + * + * If the date time input or time zone is invalid, `None` will be returned. + * + * @since 3.6.0 + * @category constructors + * @example + * import { DateTime } from "effect" + * + * DateTime.makeZoned(new Date(), { timeZone: "Europe/London" }) + */ +export const makeZoned: ( + input: DateTime.Input, + options: { + readonly timeZone: number | string | TimeZone + readonly adjustForTimeZone?: boolean | undefined + } +) => Option.Option = Option + .liftThrowable(unsafeMakeZoned) + +/** + * Create a `DateTime` from one of the following: + * + * - A `DateTime` + * - A `Date` instance (invalid dates will throw an `IllegalArgumentException`) + * - The `number` of milliseconds since the Unix epoch + * - An object with the parts of a date + * - A `string` that can be parsed by `Date.parse` + * + * If the input is invalid, `None` will be returned. + * + * @since 3.6.0 + * @category constructors + * @example + * import { DateTime } from "effect" + * + * // from Date + * DateTime.make(new Date()) + * + * // from parts + * DateTime.make({ year: 2024 }) + * + * // from string + * DateTime.make("2024-01-01") + */ +export const make: (input: A) => Option.Option> = Option + .liftThrowable(unsafeMake) + +const zonedStringRegex = /^(.{17,35})\[(.+)\]$/ + +/** + * Create a `DateTime.Zoned` from a string. + * + * It uses the format: `YYYY-MM-DDTHH:mm:ss.sss+HH:MM[Time/Zone]`. + * + * @since 3.6.0 + * @category constructors + */ +export const makeZonedFromString = (input: string): Option.Option => { + const match = zonedStringRegex.exec(input) + if (match === null) { + const offset = parseOffset(input) + return offset ? makeZoned(input, { timeZone: offset }) : Option.none() + } + const [, isoString, timeZone] = match + return makeZoned(isoString, { timeZone }) +} + +/** + * Get the current time using the `Clock` service and convert it to a `DateTime`. + * + * @since 3.6.0 + * @category constructors + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * }) + */ +export const now: Effect.Effect = Effect.map(Clock.currentTimeMillis, makeUtc) + +/** + * Get the current time using `Date.now`. + * + * @since 3.6.0 + * @category constructors + */ +export const unsafeNow: LazyArg = () => makeUtc(Date.now()) + +// ============================================================================= +// time zones +// ============================================================================= + +/** + * Set the time zone of a `DateTime`, returning a new `DateTime.Zoned`. + * + * @since 3.6.0 + * @category time zones + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * const zone = DateTime.zoneUnsafeMakeNamed("Europe/London") + * + * // set the time zone + * const zoned: DateTime.Zoned = DateTime.setZone(now, zone) + * }) + */ +export const setZone: { + (zone: TimeZone, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): (self: DateTime) => Zoned + (self: DateTime, zone: TimeZone, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): Zoned +} = dual(isDateTimeArgs, (self: DateTime, zone: TimeZone, options?: { + readonly adjustForTimeZone?: boolean | undefined +}): Zoned => + options?.adjustForTimeZone === true + ? makeZonedFromAdjusted(self.epochMillis, zone) + : makeZonedProto(self.epochMillis, zone, self.partsUtc)) + +/** + * Add a fixed offset time zone to a `DateTime`. + * + * The offset is in milliseconds. + * + * @since 3.6.0 + * @category time zones + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * + * // set the offset time zone in milliseconds + * const zoned: DateTime.Zoned = DateTime.setZoneOffset(now, 3 * 60 * 60 * 1000) + * }) + */ +export const setZoneOffset: { + (offset: number, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): (self: DateTime) => Zoned + (self: DateTime, offset: number, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): Zoned +} = dual(isDateTimeArgs, (self: DateTime, offset: number, options?: { + readonly adjustForTimeZone?: boolean | undefined +}): Zoned => setZone(self, zoneMakeOffset(offset), options)) + +const validZoneCache = globalValue("effect/DateTime/validZoneCache", () => new Map()) + +const formatOptions: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + timeZoneName: "longOffset", + fractionalSecondDigits: 3, + hourCycle: "h23" +} + +const zoneMakeIntl = (format: Intl.DateTimeFormat): TimeZone.Named => { + const zoneId = format.resolvedOptions().timeZone + if (validZoneCache.has(zoneId)) { + return validZoneCache.get(zoneId)! + } + const zone = Object.create(ProtoTimeZoneNamed) + zone.id = zoneId + zone.format = format + validZoneCache.set(zoneId, zone) + return zone +} + +/** + * Attempt to create a named time zone from a IANA time zone identifier. + * + * If the time zone is invalid, an `IllegalArgumentException` will be thrown. + * + * @since 3.6.0 + * @category time zones + */ +export const zoneUnsafeMakeNamed = (zoneId: string): TimeZone.Named => { + if (validZoneCache.has(zoneId)) { + return validZoneCache.get(zoneId)! + } + try { + return zoneMakeIntl( + new Intl.DateTimeFormat("en-US", { + ...formatOptions, + timeZone: zoneId + }) + ) + } catch (_) { + throw new IllegalArgumentException(`Invalid time zone: ${zoneId}`) + } +} + +/** + * Create a fixed offset time zone. + * + * @since 3.6.0 + * @category time zones + */ +export const zoneMakeOffset = (offset: number): TimeZone.Offset => { + const zone = Object.create(ProtoTimeZoneOffset) + zone.offset = offset + return zone +} + +/** + * Create a named time zone from a IANA time zone identifier. If the time zone + * is invalid, `None` will be returned. + * + * @since 3.6.0 + * @category time zones + */ +export const zoneMakeNamed: (zoneId: string) => Option.Option = Option.liftThrowable( + zoneUnsafeMakeNamed +) + +/** + * Create a named time zone from a IANA time zone identifier. If the time zone + * is invalid, it will fail with an `IllegalArgumentException`. + * + * @since 3.6.0 + * @category time zones + */ +export const zoneMakeNamedEffect = (zoneId: string): Effect.Effect => + Effect.try({ + try: () => zoneUnsafeMakeNamed(zoneId), + catch: (e) => e as IllegalArgumentException + }) + +/** + * Create a named time zone from the system's local time zone. + * + * @since 3.6.0 + * @category time zones + */ +export const zoneMakeLocal = (): TimeZone.Named => zoneMakeIntl(new Intl.DateTimeFormat("en-US", formatOptions)) + +const offsetZoneRegex = /^(?:GMT|[+-])/ + +/** + * Try parse a TimeZone from a string + * + * @since 3.6.0 + * @category time zones + */ +export const zoneFromString = (zone: string): Option.Option => { + if (offsetZoneRegex.test(zone)) { + const offset = parseOffset(zone) + return offset === null ? Option.none() : Option.some(zoneMakeOffset(offset)) + } + return zoneMakeNamed(zone) +} + +/** + * Format a `TimeZone` as a string. + * + * @since 3.6.0 + * @category time zones + * @example + * import { DateTime, Effect } from "effect" + * + * // Outputs "+03:00" + * DateTime.zoneToString(DateTime.zoneMakeOffset(3 * 60 * 60 * 1000)) + * + * // Outputs "Europe/London" + * DateTime.zoneToString(DateTime.zoneUnsafeMakeNamed("Europe/London")) + */ +export const zoneToString = (self: TimeZone): string => { + if (self._tag === "Offset") { + return offsetToString(self.offset) + } + return self.id +} + +/** + * Set the time zone of a `DateTime` from an IANA time zone identifier. If the + * time zone is invalid, `None` will be returned. + * + * @since 3.6.0 + * @category time zones + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * // set the time zone, returns an Option + * DateTime.setZoneNamed(now, "Europe/London") + * }) + */ +export const setZoneNamed: { + (zoneId: string, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): (self: DateTime) => Option.Option + (self: DateTime, zoneId: string, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): Option.Option +} = dual( + isDateTimeArgs, + (self: DateTime, zoneId: string, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): Option.Option => Option.map(zoneMakeNamed(zoneId), (zone) => setZone(self, zone, options)) +) + +/** + * Set the time zone of a `DateTime` from an IANA time zone identifier. If the + * time zone is invalid, an `IllegalArgumentException` will be thrown. + * + * @since 3.6.0 + * @category time zones + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * // set the time zone + * DateTime.unsafeSetZoneNamed(now, "Europe/London") + * }) + */ +export const unsafeSetZoneNamed: { + (zoneId: string, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): (self: DateTime) => Zoned + (self: DateTime, zoneId: string, options?: { + readonly adjustForTimeZone?: boolean | undefined + }): Zoned +} = dual(isDateTimeArgs, (self: DateTime, zoneId: string, options?: { + readonly adjustForTimeZone?: boolean | undefined +}): Zoned => setZone(self, zoneUnsafeMakeNamed(zoneId), options)) + +// ============================================================================= +// comparisons +// ============================================================================= + +/** + * Calulate the difference between two `DateTime` values, returning the number + * of milliseconds the `other` DateTime is from `self`. + * + * If `other` is *after* `self`, the result will be a positive number. + * + * @since 3.6.0 + * @category comparisons + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * const other = DateTime.add(now, { minutes: 1 }) + * + * // returns 60000 + * DateTime.distance(now, other) + * }) + */ +export const distance: { + (other: DateTime): (self: DateTime) => number + (self: DateTime, other: DateTime): number +} = dual(2, (self: DateTime, other: DateTime): number => toEpochMillis(other) - toEpochMillis(self)) + +/** + * Calulate the difference between two `DateTime` values. + * + * If the `other` DateTime is before `self`, the result will be a negative + * `Duration`, returned as a `Left`. + * + * If the `other` DateTime is after `self`, the result will be a positive + * `Duration`, returned as a `Right`. + * + * @since 3.6.0 + * @category comparisons + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * const other = DateTime.add(now, { minutes: 1 }) + * + * // returns Either.right(Duration.minutes(1)) + * DateTime.distanceDurationEither(now, other) + * + * // returns Either.left(Duration.minutes(1)) + * DateTime.distanceDurationEither(other, now) + * }) + */ +export const distanceDurationEither: { + (other: DateTime): (self: DateTime) => Either.Either + (self: DateTime, other: DateTime): Either.Either +} = dual(2, (self: DateTime, other: DateTime): Either.Either => { + const diffMillis = distance(self, other) + return diffMillis > 0 + ? Either.right(Duration.millis(diffMillis)) + : Either.left(Duration.millis(-diffMillis)) +}) + +/** + * Calulate the distance between two `DateTime` values. + * + * @since 3.6.0 + * @category comparisons + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * const other = DateTime.add(now, { minutes: 1 }) + * + * // returns Duration.minutes(1) + * DateTime.distanceDuration(now, other) + * }) + */ +export const distanceDuration: { + (other: DateTime): (self: DateTime) => Duration.Duration + (self: DateTime, other: DateTime): Duration.Duration +} = dual( + 2, + (self: DateTime, other: DateTime): Duration.Duration => Duration.millis(Math.abs(distance(self, other))) +) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const min: { + (that: DateTime): (self: DateTime) => DateTime + (self: DateTime, that: DateTime): DateTime +} = order.min(Order) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const max: { + (that: DateTime): (self: DateTime) => DateTime + (self: DateTime, that: DateTime): DateTime +} = order.max(Order) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const greaterThan: { + (that: DateTime): (self: DateTime) => boolean + (self: DateTime, that: DateTime): boolean +} = order.greaterThan(Order) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const greaterThanOrEqualTo: { + (that: DateTime): (self: DateTime) => boolean + (self: DateTime, that: DateTime): boolean +} = order.greaterThanOrEqualTo(Order) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const lessThan: { + (that: DateTime): (self: DateTime) => boolean + (self: DateTime, that: DateTime): boolean +} = order.lessThan(Order) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const lessThanOrEqualTo: { + (that: DateTime): (self: DateTime) => boolean + (self: DateTime, that: DateTime): boolean +} = order.lessThanOrEqualTo(Order) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const between: { + (options: { minimum: DateTime; maximum: DateTime }): (self: DateTime) => boolean + (self: DateTime, options: { minimum: DateTime; maximum: DateTime }): boolean +} = order.between(Order) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const isFuture = (self: DateTime): Effect.Effect => Effect.map(now, lessThan(self)) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const unsafeIsFuture = (self: DateTime): boolean => lessThan(unsafeNow(), self) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const isPast = (self: DateTime): Effect.Effect => Effect.map(now, greaterThan(self)) + +/** + * @since 3.6.0 + * @category comparisons + */ +export const unsafeIsPast = (self: DateTime): boolean => greaterThan(unsafeNow(), self) + +// ============================================================================= +// conversions +// ============================================================================= + +/** + * Get the UTC `Date` of a `DateTime`. + * + * @since 3.6.0 + * @category conversions + */ +export const toDateUtc = (self: DateTime): Date => new Date(self.epochMillis) + +/** + * Convert a `DateTime` to a `Date`, applying the time zone first. + * + * @since 3.6.0 + * @category conversions + */ +export const toDate = (self: DateTime): Date => { + if (self._tag === "Utc") { + return new Date(self.epochMillis) + } else if (self.zone._tag === "Offset") { + return new Date(self.epochMillis + self.zone.offset) + } else if (self.adjustedEpochMillis !== undefined) { + return new Date(self.adjustedEpochMillis) + } + const parts = self.zone.format.formatToParts(self.epochMillis).filter((_) => _.type !== "literal") + const date = new Date(0) + date.setUTCFullYear( + Number(parts[2].value), + Number(parts[0].value) - 1, + Number(parts[1].value) + ) + date.setUTCHours( + Number(parts[3].value), + Number(parts[4].value), + Number(parts[5].value), + Number(parts[6].value) + ) + self.adjustedEpochMillis = date.getTime() + return date +} + +/** + * Calculate the time zone offset of a `DateTime.Zoned` in milliseconds. + * + * @since 3.6.0 + * @category conversions + */ +export const zonedOffset = (self: Zoned): number => { + const date = toDate(self) + return date.getTime() - toEpochMillis(self) +} + +const offsetToString = (offset: number): string => { + const abs = Math.abs(offset) + const hours = Math.floor(abs / (60 * 60 * 1000)) + const minutes = Math.round((abs % (60 * 60 * 1000)) / (60 * 1000)) + return `${offset < 0 ? "-" : "+"}${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}` +} + +/** + * Calculate the time zone offset of a `DateTime` in milliseconds. + * + * The offset is formatted as "±HH:MM". + * + * @since 3.6.0 + * @category conversions + */ +export const zonedOffsetIso = (self: Zoned): string => offsetToString(zonedOffset(self)) + +/** + * Get the milliseconds since the Unix epoch of a `DateTime`. + * + * @since 3.6.0 + * @category conversions + */ +export const toEpochMillis = (self: DateTime): number => self.epochMillis + +/** + * Remove the time aspect of a `DateTime`, first adjusting for the time + * zone. It will return a `DateTime.Utc` only containing the date. + * + * @since 3.6.0 + * @category conversions + * @example + * import { DateTime } from "effect" + * + * // returns "2024-01-01T00:00:00Z" + * DateTime.unsafeMakeZoned("2024-01-01T05:00:00Z", { + * timeZone: "Pacific/Auckland", + * adjustForTimeZone: true + * }).pipe( + * DateTime.removeTime, + * DateTime.formatIso + * ) + */ +export const removeTime = (self: DateTime): Utc => + withDate(self, (date) => { + date.setUTCHours(0, 0, 0, 0) + return makeUtc(date.getTime()) + }) + +// ============================================================================= +// parts +// ============================================================================= + +const dateToParts = (date: Date): DateTime.PartsWithWeekday => ({ + millis: date.getUTCMilliseconds(), + seconds: date.getUTCSeconds(), + minutes: date.getUTCMinutes(), + hours: date.getUTCHours(), + day: date.getUTCDate(), + weekDay: date.getUTCDay(), + month: date.getUTCMonth() + 1, + year: date.getUTCFullYear() +}) + +/** + * Get the different parts of a `DateTime` as an object. + * + * The parts will be time zone adjusted. + * + * @since 3.6.0 + * @category parts + */ +export const toParts = (self: DateTime): DateTime.PartsWithWeekday => { + if (self._tag === "Utc") { + return toPartsUtc(self) + } else if (self.partsAdjusted !== undefined) { + return self.partsAdjusted + } + self.partsAdjusted = withDate(self, dateToParts) + return self.partsAdjusted +} + +/** + * Get the different parts of a `DateTime` as an object. + * + * The parts will be in UTC. + * + * @since 3.6.0 + * @category parts + */ +export const toPartsUtc = (self: DateTime): DateTime.PartsWithWeekday => { + if (self.partsUtc !== undefined) { + return self.partsUtc + } + self.partsUtc = withDateUtc(self, dateToParts) + return self.partsUtc +} + +/** + * Get a part of a `DateTime` as a number. + * + * The part will be in the UTC time zone. + * + * @since 3.6.0 + * @category parts + * @example + * import { DateTime } from "effect" + * + * const now = DateTime.unsafeMake({ year: 2024 }) + * const year = DateTime.getPartUtc(now, "year") + * assert.strictEqual(year, 2024) + */ +export const getPartUtc: { + (part: keyof DateTime.PartsWithWeekday): (self: DateTime) => number + (self: DateTime, part: keyof DateTime.PartsWithWeekday): number +} = dual(2, (self: DateTime, part: keyof DateTime.PartsWithWeekday): number => toPartsUtc(self)[part]) + +/** + * Get a part of a `DateTime` as a number. + * + * The part will be time zone adjusted. + * + * @since 3.6.0 + * @category parts + * @example + * import { DateTime } from "effect" + * + * const now = DateTime.unsafeMakeZoned({ year: 2024 }, { timeZone: "Europe/London" }) + * const year = DateTime.getPart(now, "year") + * assert.strictEqual(year, 2024) + */ +export const getPart: { + (part: keyof DateTime.PartsWithWeekday): (self: DateTime) => number + (self: DateTime, part: keyof DateTime.PartsWithWeekday): number +} = dual(2, (self: DateTime, part: keyof DateTime.PartsWithWeekday): number => toParts(self)[part]) + +const setPartsDate = (date: Date, parts: Partial): void => { + if (parts.year !== undefined) { + date.setUTCFullYear(parts.year) + } + if (parts.month !== undefined) { + date.setUTCMonth(parts.month - 1) + } + if (parts.day !== undefined) { + date.setUTCDate(parts.day) + } + if (parts.weekDay !== undefined) { + const diff = parts.weekDay - date.getUTCDay() + date.setUTCDate(date.getUTCDate() + diff) + } + if (parts.hours !== undefined) { + date.setUTCHours(parts.hours) + } + if (parts.minutes !== undefined) { + date.setUTCMinutes(parts.minutes) + } + if (parts.seconds !== undefined) { + date.setUTCSeconds(parts.seconds) + } + if (parts.millis !== undefined) { + date.setUTCMilliseconds(parts.millis) + } +} + +/** + * Set the different parts of a `DateTime` as an object. + * + * The Date will be time zone adjusted. + * + * @since 3.6.0 + * @category parts + */ +export const setParts: { + (parts: Partial): (self: A) => DateTime.PreserveZone + (self: A, parts: Partial): DateTime.PreserveZone +} = dual( + 2, + (self: DateTime, parts: Partial): DateTime => + mutate(self, (date) => setPartsDate(date, parts)) +) + +/** + * Set the different parts of a `DateTime` as an object. + * + * @since 3.6.0 + * @category parts + */ +export const setPartsUtc: { + (parts: Partial): (self: A) => DateTime.PreserveZone + (self: A, parts: Partial): DateTime.PreserveZone +} = dual( + 2, + (self: DateTime, parts: Partial): DateTime => + mutateUtc(self, (date) => setPartsDate(date, parts)) +) + +// ============================================================================= +// current time zone +// ============================================================================= + +/** + * @since 3.6.0 + * @category current time zone + */ +export class CurrentTimeZone extends Context.Tag("effect/DateTime/CurrentTimeZone")< + CurrentTimeZone, + TimeZone +>() {} + +/** + * Set the time zone of a `DateTime` to the current time zone, which is + * determined by the `CurrentTimeZone` service. + * + * @since 3.6.0 + * @category current time zone + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * const now = yield* DateTime.now + * + * // set the time zone to "Europe/London" + * const zoned = yield* DateTime.setZoneCurrent(now) + * }).pipe(DateTime.withCurrentZoneNamed("Europe/London")) + */ +export const setZoneCurrent = (self: DateTime): Effect.Effect => + Effect.map(CurrentTimeZone, (zone) => setZone(self, zone)) + +/** + * Provide the `CurrentTimeZone` to an effect. + * + * @since 3.6.0 + * @category current time zone + * @example + * import { DateTime, Effect } from "effect" + * + * const zone = DateTime.zoneUnsafeMakeNamed("Europe/London") + * + * Effect.gen(function* () { + * const now = yield* DateTime.nowInCurrentZone + * }).pipe(DateTime.withCurrentZone(zone)) + */ +export const withCurrentZone: { + (zone: TimeZone): (effect: Effect.Effect) => Effect.Effect> + (effect: Effect.Effect, zone: TimeZone): Effect.Effect> +} = dual( + 2, + (effect: Effect.Effect, zone: TimeZone): Effect.Effect> => + Effect.provideService(effect, CurrentTimeZone, zone) +) + +/** + * Provide the `CurrentTimeZone` to an effect, using the system's local time + * zone. + * + * @since 3.6.0 + * @category current time zone + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * // will use the system's local time zone + * const now = yield* DateTime.nowInCurrentZone + * }).pipe(DateTime.withCurrentZoneLocal) + */ +export const withCurrentZoneLocal = ( + effect: Effect.Effect +): Effect.Effect> => + Effect.provideServiceEffect(effect, CurrentTimeZone, Effect.sync(zoneMakeLocal)) + +/** + * Provide the `CurrentTimeZone` to an effect, using a offset. + * + * @since 3.6.0 + * @category current time zone + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * // will use the system's local time zone + * const now = yield* DateTime.nowInCurrentZone + * }).pipe(DateTime.withCurrentZoneOffset(3 * 60 * 60 * 1000)) + */ +export const withCurrentZoneOffset: { + (offset: number): ( + effect: Effect.Effect + ) => Effect.Effect> + (effect: Effect.Effect, offset: number): Effect.Effect> +} = dual( + 2, + (effect: Effect.Effect, offset: number): Effect.Effect> => + Effect.provideService(effect, CurrentTimeZone, zoneMakeOffset(offset)) +) + +/** + * Provide the `CurrentTimeZone` to an effect using an IANA time zone + * identifier. + * + * If the time zone is invalid, it will fail with an `IllegalArgumentException`. + * + * @since 3.6.0 + * @category current time zone + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * // will use the "Europe/London" time zone + * const now = yield* DateTime.nowInCurrentZone + * }).pipe(DateTime.withCurrentZoneNamed("Europe/London")) + */ +export const withCurrentZoneNamed: { + (zone: string): ( + effect: Effect.Effect + ) => Effect.Effect> + ( + effect: Effect.Effect, + zone: string + ): Effect.Effect> +} = dual( + 2, + ( + effect: Effect.Effect, + zone: string + ): Effect.Effect> => + Effect.provideServiceEffect(effect, CurrentTimeZone, zoneMakeNamedEffect(zone)) +) + +/** + * Get the current time as a `DateTime.Zoned`, using the `CurrentTimeZone`. + * + * @since 3.6.0 + * @category current time zone + * @example + * import { DateTime, Effect } from "effect" + * + * Effect.gen(function* () { + * // will use the "Europe/London" time zone + * const now = yield* DateTime.nowInCurrentZone + * }).pipe(DateTime.withCurrentZoneNamed("Europe/London")) + */ +export const nowInCurrentZone: Effect.Effect = Effect.flatMap( + now, + setZoneCurrent +) + +/** + * Create a Layer from the given time zone. + * + * @since 3.6.0 + * @category current time zone + */ +export const layerCurrentZone = (zone: TimeZone): Layer.Layer => Layer.succeed(CurrentTimeZone, zone) + +/** + * Create a Layer from the given time zone offset. + * + * @since 3.6.0 + * @category current time zone + */ +export const layerCurrentZoneOffset = (offset: number): Layer.Layer => + Layer.succeed(CurrentTimeZone, zoneMakeOffset(offset)) + +/** + * Create a Layer from the given IANA time zone identifier. + * + * @since 3.6.0 + * @category current time zone + */ +export const layerCurrentZoneNamed = (zoneId: string): Layer.Layer => + Layer.effect(CurrentTimeZone, zoneMakeNamedEffect(zoneId)) + +/** + * Create a Layer from the systems local time zone. + * + * @since 3.6.0 + * @category current time zone + */ +export const layerCurrentZoneLocal: Layer.Layer = Layer.sync( + CurrentTimeZone, + zoneMakeLocal +) + +// ============================================================================= +// mapping +// ============================================================================= + +const makeZonedFromAdjusted = (adjustedMillis: number, zone: TimeZone): Zoned => { + const offset = zone._tag === "Offset" ? zone.offset : calculateNamedOffset(adjustedMillis, zone) + return makeZonedProto(adjustedMillis - offset, zone) +} + +const offsetRegex = /([+-])(\d{2}):(\d{2})$/ +const parseOffset = (offset: string): number | null => { + const match = offsetRegex.exec(offset) + if (match === null) { + return null + } + const [, sign, hours, minutes] = match + return (sign === "+" ? 1 : -1) * (Number(hours) * 60 + Number(minutes)) * 60 * 1000 +} + +const calculateNamedOffset = (adjustedMillis: number, zone: TimeZone.Named): number => { + const offset = zone.format.formatToParts(adjustedMillis).find((_) => _.type === "timeZoneName")?.value ?? "" + if (offset === "GMT") { + return 0 + } + const result = parseOffset(offset) + if (result === null) { + // fallback to using the adjusted date + return zonedOffset(makeZonedProto(adjustedMillis, zone)) + } + return result +} + +/** + * Modify a `DateTime` by applying a function to a cloned `Date` instance. + * + * The `Date` will first have the time zone applied if possible, and then be + * converted back to a `DateTime` within the same time zone. + * + * @since 3.6.0 + * @category mapping + */ +export const mutate: { + (f: (date: Date) => void): (self: A) => DateTime.PreserveZone + (self: A, f: (date: Date) => void): DateTime.PreserveZone +} = dual(2, (self: DateTime, f: (date: Date) => void): DateTime => { + if (self._tag === "Utc") { + const date = toDateUtc(self) + f(date) + return makeUtc(date.getTime()) + } + const adjustedDate = toDate(self) + const newAdjustedDate = new Date(adjustedDate.getTime()) + f(newAdjustedDate) + return makeZonedFromAdjusted(newAdjustedDate.getTime(), self.zone) +}) + +/** + * Modify a `DateTime` by applying a function to a cloned UTC `Date` instance. + * + * @since 3.6.0 + * @category mapping + */ +export const mutateUtc: { + (f: (date: Date) => void): (self: A) => DateTime.PreserveZone + (self: A, f: (date: Date) => void): DateTime.PreserveZone +} = dual(2, (self: DateTime, f: (date: Date) => void): DateTime => + mapEpochMillis(self, (millis) => { + const date = new Date(millis) + f(date) + return date.getTime() + })) + +/** + * Transform a `DateTime` by applying a function to the number of milliseconds + * since the Unix epoch. + * + * @since 3.6.0 + * @category mapping + * @example + * import { DateTime } from "effect" + * + * // add 10 milliseconds + * DateTime.unsafeMake(0).pipe( + * DateTime.mapEpochMillis((millis) => millis + 10) + * ) + */ +export const mapEpochMillis: { + (f: (millis: number) => number): (self: A) => DateTime.PreserveZone + (self: A, f: (millis: number) => number): DateTime.PreserveZone +} = dual(2, (self: DateTime, f: (millis: number) => number): DateTime => { + const millis = f(toEpochMillis(self)) + return self._tag === "Utc" ? makeUtc(millis) : makeZonedProto(millis, self.zone) +}) + +/** + * Using the time zone adjusted `Date`, apply a function to the `Date` and + * return the result. + * + * @since 3.6.0 + * @category mapping + * @example + * import { DateTime } from "effect" + * + * // get the time zone adjusted date in milliseconds + * DateTime.unsafeMakeZoned(0, { timeZone: "Europe/London" }).pipe( + * DateTime.withDate((date) => date.getTime()) + * ) + */ +export const withDate: { + (f: (date: Date) => A): (self: DateTime) => A + (self: DateTime, f: (date: Date) => A): A +} = dual(2, (self: DateTime, f: (date: Date) => A): A => f(toDate(self))) + +/** + * Using the time zone adjusted `Date`, apply a function to the `Date` and + * return the result. + * + * @since 3.6.0 + * @category mapping + * @example + * import { DateTime } from "effect" + * + * // get the date in milliseconds + * DateTime.unsafeMake(0).pipe( + * DateTime.withDateUtc((date) => date.getTime()) + * ) + */ +export const withDateUtc: { + (f: (date: Date) => A): (self: DateTime) => A + (self: DateTime, f: (date: Date) => A): A +} = dual(2, (self: DateTime, f: (date: Date) => A): A => f(toDateUtc(self))) + +/** + * @since 3.6.0 + * @category mapping + */ +export const match: { + (options: { + readonly onUtc: (_: Utc) => A + readonly onZoned: (_: Zoned) => B + }): (self: DateTime) => A | B + (self: DateTime, options: { + readonly onUtc: (_: Utc) => A + readonly onZoned: (_: Zoned) => B + }): A | B +} = dual(2, (self: DateTime, options: { + readonly onUtc: (_: Utc) => A + readonly onZoned: (_: Zoned) => B +}): A | B => self._tag === "Utc" ? options.onUtc(self) : options.onZoned(self)) + +// ============================================================================= +// math +// ============================================================================= + +/** + * Add the given `Duration` to a `DateTime`. + * + * @since 3.6.0 + * @category math + * @example + * import { DateTime } from "effect" + * + * // add 5 minutes + * DateTime.unsafeMake(0).pipe( + * DateTime.addDuration("5 minutes") + * ) + */ +export const addDuration: { + (duration: Duration.DurationInput): (self: A) => DateTime.PreserveZone + (self: A, duration: Duration.DurationInput): DateTime.PreserveZone +} = dual( + 2, + (self: DateTime, duration: Duration.DurationInput): DateTime => + mapEpochMillis(self, (millis) => millis + Duration.toMillis(duration)) +) + +/** + * Subtract the given `Duration` from a `DateTime`. + * + * @since 3.6.0 + * @category math + * @example + * import { DateTime } from "effect" + * + * // subtract 5 minutes + * DateTime.unsafeMake(0).pipe( + * DateTime.subtractDuration("5 minutes") + * ) + */ +export const subtractDuration: { + (duration: Duration.DurationInput): (self: A) => DateTime.PreserveZone + (self: A, duration: Duration.DurationInput): DateTime.PreserveZone +} = dual( + 2, + (self: DateTime, duration: Duration.DurationInput): DateTime => + mapEpochMillis(self, (millis) => millis - Duration.toMillis(duration)) +) + +const addMillis = (date: Date, amount: number): void => { + date.setTime(date.getTime() + amount) +} + +/** + * Add the given `amount` of `unit`'s to a `DateTime`. + * + * The time zone is taken into account when adding days, weeks, months, and + * years. + * + * @since 3.6.0 + * @category math + * @example + * import { DateTime } from "effect" + * + * // add 5 minutes + * DateTime.unsafeMake(0).pipe( + * DateTime.add({ minutes: 5 }) + * ) + */ +export const add: { + (parts: Partial): (self: A) => DateTime.PreserveZone + (self: A, parts: Partial): DateTime.PreserveZone +} = dual(2, (self: DateTime, parts: Partial): DateTime => + mutate(self, (date) => { + if (parts.millis) { + addMillis(date, parts.millis) + } + if (parts.seconds) { + addMillis(date, parts.seconds * 1000) + } + if (parts.minutes) { + addMillis(date, parts.minutes * 60 * 1000) + } + if (parts.hours) { + addMillis(date, parts.hours * 60 * 60 * 1000) + } + if (parts.days) { + date.setUTCDate(date.getUTCDate() + parts.days) + } + if (parts.weeks) { + date.setUTCDate(date.getUTCDate() + parts.weeks * 7) + } + if (parts.months) { + const day = date.getUTCDate() + date.setUTCMonth(date.getUTCMonth() + parts.months + 1, 0) + if (day < date.getUTCDate()) { + date.setUTCDate(day) + } + } + if (parts.years) { + const day = date.getUTCDate() + const month = date.getUTCMonth() + date.setUTCFullYear( + date.getUTCFullYear() + parts.years, + month + 1, + 0 + ) + if (day < date.getUTCDate()) { + date.setUTCDate(day) + } + } + })) + +/** + * Subtract the given `amount` of `unit`'s from a `DateTime`. + * + * @since 3.6.0 + * @category math + * @example + * import { DateTime } from "effect" + * + * // subtract 5 minutes + * DateTime.unsafeMake(0).pipe( + * DateTime.subtract({ minutes: 5 }) + * ) + */ +export const subtract: { + (parts: Partial): (self: A) => DateTime.PreserveZone + (self: A, parts: Partial): DateTime.PreserveZone +} = dual(2, (self: DateTime, parts: Partial): DateTime => { + const newParts = {} as Partial> + for (const key in parts) { + newParts[key as keyof DateTime.PartsForMath] = -1 * parts[key as keyof DateTime.PartsForMath]! + } + return add(self, newParts) +}) + +function startOfDate(date: Date, part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined +}) { + switch (part) { + case "second": { + date.setUTCMilliseconds(0) + break + } + case "minute": { + date.setUTCSeconds(0, 0) + break + } + case "hour": { + date.setUTCMinutes(0, 0, 0) + break + } + case "day": { + date.setUTCHours(0, 0, 0, 0) + break + } + case "week": { + const weekStartsOn = options?.weekStartsOn ?? 0 + const day = date.getUTCDay() + const diff = (day - weekStartsOn + 7) % 7 + date.setUTCDate(date.getUTCDate() - diff) + date.setUTCHours(0, 0, 0, 0) + break + } + case "month": { + date.setUTCDate(1) + date.setUTCHours(0, 0, 0, 0) + break + } + case "year": { + date.setUTCMonth(0, 1) + date.setUTCHours(0, 0, 0, 0) + break + } + } +} + +/** + * Converts a `DateTime` to the start of the given `part`. + * + * If the part is `week`, the `weekStartsOn` option can be used to specify the + * day of the week that the week starts on. The default is 0 (Sunday). + * + * @since 3.6.0 + * @category math + * @example + * import { DateTime } from "effect" + * + * // returns "2024-01-01T00:00:00Z" + * DateTime.unsafeMake("2024-01-01T12:00:00Z").pipe( + * DateTime.startOf("day"), + * DateTime.formatIso + * ) + */ +export const startOf: { + (part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined + }): (self: A) => DateTime.PreserveZone + (self: A, part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined + }): DateTime.PreserveZone +} = dual(isDateTimeArgs, (self: DateTime, part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined +}): DateTime => mutate(self, (date) => startOfDate(date, part, options))) + +function endOfDate(date: Date, part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined +}) { + switch (part) { + case "second": { + date.setUTCMilliseconds(999) + break + } + case "minute": { + date.setUTCSeconds(59, 999) + break + } + case "hour": { + date.setUTCMinutes(59, 59, 999) + break + } + case "day": { + date.setUTCHours(23, 59, 59, 999) + break + } + case "week": { + const weekStartsOn = options?.weekStartsOn ?? 0 + const day = date.getUTCDay() + const diff = (day - weekStartsOn + 7) % 7 + date.setUTCDate(date.getUTCDate() - diff + 6) + date.setUTCHours(23, 59, 59, 999) + break + } + case "month": { + date.setUTCMonth(date.getUTCMonth() + 1, 0) + date.setUTCHours(23, 59, 59, 999) + break + } + case "year": { + date.setUTCMonth(11, 31) + date.setUTCHours(23, 59, 59, 999) + break + } + } +} + +/** + * Converts a `DateTime` to the end of the given `part`. + * + * If the part is `week`, the `weekStartsOn` option can be used to specify the + * day of the week that the week starts on. The default is 0 (Sunday). + * + * @since 3.6.0 + * @category math + * @example + * import { DateTime } from "effect" + * + * // returns "2024-01-01T23:59:59.999Z" + * DateTime.unsafeMake("2024-01-01T12:00:00Z").pipe( + * DateTime.endOf("day"), + * DateTime.formatIso + * ) + */ +export const endOf: { + (part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined + }): (self: A) => DateTime.PreserveZone + (self: A, part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined + }): DateTime.PreserveZone +} = dual(isDateTimeArgs, (self: DateTime, part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined +}): DateTime => mutate(self, (date) => endOfDate(date, part, options))) + +/** + * Converts a `DateTime` to the nearest given `part`. + * + * If the part is `week`, the `weekStartsOn` option can be used to specify the + * day of the week that the week starts on. The default is 0 (Sunday). + * + * @since 3.6.0 + * @category math + * @example + * import { DateTime } from "effect" + * + * // returns "2024-01-02T00:00:00Z" + * DateTime.unsafeMake("2024-01-01T12:01:00Z").pipe( + * DateTime.nearest("day"), + * DateTime.formatIso + * ) + */ +export const nearest: { + (part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined + }): (self: A) => DateTime.PreserveZone + (self: A, part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined + }): DateTime.PreserveZone +} = dual(isDateTimeArgs, (self: DateTime, part: DateTime.UnitSingular, options?: { + readonly weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined +}): DateTime => + mutate(self, (date) => { + if (part === "milli") return + const millis = date.getTime() + const start = new Date(millis) + startOfDate(start, part, options) + const startMillis = start.getTime() + const end = new Date(millis) + endOfDate(end, part, options) + const endMillis = end.getTime() + 1 + const diffStart = millis - startMillis + const diffEnd = endMillis - millis + if (diffStart < diffEnd) { + date.setTime(startMillis) + } else { + date.setTime(endMillis) + } + })) + +// ============================================================================= +// formatting +// ============================================================================= + +const intlTimeZone = (self: TimeZone): string => { + if (self._tag === "Named") { + return self.id + } + return offsetToString(self.offset) +} + +/** + * Format a `DateTime` as a string using the `DateTimeFormat` API. + * + * The `timeZone` option is set to the offset of the time zone. + * + * Note: On Node versions < 22, fixed "Offset" zones will set the time zone to + * "UTC" and use the adjusted `Date`. + * + * @since 3.6.0 + * @category formatting + */ +export const format: { + ( + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined + ): (self: DateTime) => string + ( + self: DateTime, + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined + ): string +} = dual(isDateTimeArgs, ( + self: DateTime, + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined +): string => { + try { + return new Intl.DateTimeFormat(options?.locale, { + timeZone: self._tag === "Utc" ? "UTC" : intlTimeZone(self.zone), + ...options + }).format(self.epochMillis) + } catch (_) { + return new Intl.DateTimeFormat(options?.locale, { + timeZone: "UTC", + ...options + }).format(toDate(self)) + } +}) + +/** + * Format a `DateTime` as a string using the `DateTimeFormat` API. + * + * It will use the system's local time zone. + * + * @since 3.6.0 + * @category formatting + */ +export const formatLocal: { + ( + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined + ): (self: DateTime) => string + ( + self: DateTime, + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined + ): string +} = dual(isDateTimeArgs, ( + self: DateTime, + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined +): string => new Intl.DateTimeFormat(options?.locale, options).format(self.epochMillis)) + +/** + * Format a `DateTime` as a string using the `DateTimeFormat` API. + * + * This forces the time zone to be UTC. + * + * @since 3.6.0 + * @category formatting + */ +export const formatUtc: { + ( + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined + ): (self: DateTime) => string + ( + self: DateTime, + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined + ): string +} = dual(isDateTimeArgs, ( + self: DateTime, + options?: + | Intl.DateTimeFormatOptions & { + readonly locale?: string | undefined + } + | undefined +): string => + new Intl.DateTimeFormat(options?.locale, { + ...options, + timeZone: "UTC" + }).format(self.epochMillis)) + +/** + * Format a `DateTime` as a string using the `DateTimeFormat` API. + * + * @since 3.6.0 + * @category formatting + */ +export const formatIntl: { + (format: Intl.DateTimeFormat): (self: DateTime) => string + (self: DateTime, format: Intl.DateTimeFormat): string +} = dual(2, (self: DateTime, format: Intl.DateTimeFormat): string => format.format(self.epochMillis)) + +/** + * Format a `DateTime` as a UTC ISO string. + * + * @since 3.6.0 + * @category formatting + */ +export const formatIso = (self: DateTime): string => toDateUtc(self).toISOString() + +/** + * Format a `DateTime` as a time zone adjusted ISO date string. + * + * @since 3.6.0 + * @category formatting + */ +export const formatIsoDate = (self: DateTime): string => toDate(self).toISOString().slice(0, 10) + +/** + * Format a `DateTime` as a UTC ISO date string. + * + * @since 3.6.0 + * @category formatting + */ +export const formatIsoDateUtc = (self: DateTime): string => toDateUtc(self).toISOString().slice(0, 10) + +/** + * Format a `DateTime.Zoned` as a ISO string with an offset. + * + * @since 3.6.0 + * @category formatting + */ +export const formatIsoOffset = (self: DateTime): string => { + const date = toDate(self) + return self._tag === "Utc" ? date.toISOString() : `${date.toISOString().slice(0, -1)}${zonedOffsetIso(self)}` +} + +/** + * Format a `DateTime.Zoned` as a string. + * + * It uses the format: `YYYY-MM-DDTHH:mm:ss.sss+HH:MM[Time/Zone]`. + * + * @since 3.6.0 + * @category formatting + */ +export const formatIsoZoned = (self: Zoned): string => + self.zone._tag === "Offset" ? formatIsoOffset(self) : `${formatIsoOffset(self)}[${self.zone.id}]` diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index 276daeda32..d37b318362 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -4029,6 +4029,12 @@ export const tap: { ) => [X] extends [Effect] ? Effect : [X] extends [PromiseLike] ? Effect : Effect + ( + f: (a: NoInfer) => Effect, + options: { onlyEffect: true } + ): ( + self: Effect + ) => Effect ( f: NotFunction ): ( @@ -4036,18 +4042,34 @@ export const tap: { ) => [X] extends [Effect] ? Effect : [X] extends [PromiseLike] ? Effect : Effect + ( + f: Effect, + options: { onlyEffect: true } + ): ( + self: Effect + ) => Effect ( self: Effect, f: (a: NoInfer) => X ): [X] extends [Effect] ? Effect : [X] extends [PromiseLike] ? Effect : Effect + ( + self: Effect, + f: (a: NoInfer) => Effect, + options: { onlyEffect: true } + ): Effect ( self: Effect, f: NotFunction ): [X] extends [Effect] ? Effect : [X] extends [PromiseLike] ? Effect : Effect + ( + self: Effect, + f: Effect, + options: { onlyEffect: true } + ): Effect } = core.tap /** diff --git a/packages/effect/src/List.ts b/packages/effect/src/List.ts index 0f9f278cb9..9f6e7e90bf 100644 --- a/packages/effect/src/List.ts +++ b/packages/effect/src/List.ts @@ -29,6 +29,7 @@ import * as Equivalence from "./Equivalence.js" import { dual, identity, unsafeCoerce } from "./Function.js" import * as Hash from "./Hash.js" import { format, type Inspectable, NodeInspectSymbol, toJSON } from "./Inspectable.js" +import type { nonEmpty, NonEmptyIterable } from "./NonEmptyIterable.js" import * as Option from "./Option.js" import type { Pipeable } from "./Pipeable.js" import { pipeArguments } from "./Pipeable.js" @@ -72,7 +73,7 @@ export interface Nil extends Iterable, Equal.Equal, Pipeable, Inspecta * @since 2.0.0 * @category models */ -export interface Cons extends Iterable, Equal.Equal, Pipeable, Inspectable { +export interface Cons extends NonEmptyIterable, Equal.Equal, Pipeable, Inspectable { readonly [TypeId]: TypeId readonly _tag: "Cons" readonly head: A @@ -96,7 +97,7 @@ export const getEquivalence = (isEquivalent: Equivalence.Equivalence): Equ const _equivalence = getEquivalence(Equal.equals) -const ConsProto: Omit, "head" | "tail"> = { +const ConsProto: Omit, "head" | "tail" | typeof nonEmpty> = { [TypeId]: TypeId, _tag: "Cons", toString(this: Cons) { diff --git a/packages/effect/src/Metric.ts b/packages/effect/src/Metric.ts index be6d1cb2d4..cd33096579 100644 --- a/packages/effect/src/Metric.ts +++ b/packages/effect/src/Metric.ts @@ -361,7 +361,7 @@ export const set: { * @since 2.0.0 * @category getters */ -export const snapshot: Effect.Effect> = internal.snapshot +export const snapshot: Effect.Effect> = internal.snapshot /** * Creates a metric that ignores input and produces constant output. diff --git a/packages/effect/src/MetricRegistry.ts b/packages/effect/src/MetricRegistry.ts index 0d2c239602..e43d8519b5 100644 --- a/packages/effect/src/MetricRegistry.ts +++ b/packages/effect/src/MetricRegistry.ts @@ -25,7 +25,7 @@ export type MetricRegistryTypeId = typeof MetricRegistryTypeId */ export interface MetricRegistry { readonly [MetricRegistryTypeId]: MetricRegistryTypeId - snapshot(): ReadonlyArray + snapshot(): Array get>( key: MetricKey.MetricKey ): MetricHook.MetricHook< diff --git a/packages/effect/src/Predicate.ts b/packages/effect/src/Predicate.ts index 2d79c29d47..1646a1a9c3 100644 --- a/packages/effect/src/Predicate.ts +++ b/packages/effect/src/Predicate.ts @@ -29,6 +29,45 @@ export interface Refinement { (a: A): a is B } +/** + * @since 3.6.0 + * @category type-level + */ +export declare namespace Predicate { + /** + * @since 3.6.0 + * @category type-level + */ + export type In = [T] extends [Predicate] ? _A : never + /** + * @since 3.6.0 + * @category type-level + */ + export type Any = Predicate +} + +/** + * @since 3.6.0 + * @category type-level + */ +export declare namespace Refinement { + /** + * @since 3.6.0 + * @category type-level + */ + export type In = [T] extends [Refinement] ? _A : never + /** + * @since 3.6.0 + * @category type-level + */ + export type Out = [T] extends [Refinement] ? _B : never + /** + * @since 3.6.0 + * @category type-level + */ + export type Any = Refinement +} + /** * Given a `Predicate` returns a `Predicate` * @@ -686,23 +725,44 @@ export const productMany = ( * Similar to `Promise.all` but operates on `Predicate`s. * * ``` + * [Refinement, Refinement, ...] -> Refinement<[A, C, ...], [B, D, ...]> * [Predicate, Predicate, ...] -> Predicate<[A, B, ...]> + * [Refinement, Predicate, ...] -> Refinement<[A, C, ...], [B, C, ...]> * ``` * * @since 2.0.0 */ -export const tuple = >>( - ...elements: T -): Predicate] ? A : never }>> => all(elements) as any +export const tuple: { + >( + ...elements: T + ): [Extract] extends [never] ? Predicate<{ readonly [I in keyof T]: Predicate.In }> + : Refinement< + { readonly [I in keyof T]: T[I] extends Refinement.Any ? Refinement.In : Predicate.In }, + { readonly [I in keyof T]: T[I] extends Refinement.Any ? Refinement.Out : Predicate.In } + > +} = (...elements: ReadonlyArray) => all(elements) as any /** + * ``` + * { ab: Refinement; cd: Refinement, ... } -> Refinement<{ ab: A; cd: C; ... }, { ab: B; cd: D; ... }> + * { a: Predicate; b: Predicate, ... } -> Predicate<{ a: A; b: B; ... }> + * { ab: Refinement; c: Predicate, ... } -> Refinement<{ ab: A; c: C; ... }, { ab: B; c: С; ... }> + * ``` + * * @since 2.0.0 */ -export const struct = >>( - fields: R -): Predicate<{ readonly [K in keyof R]: [R[K]] extends [Predicate] ? A : never }> => { +export const struct: { + >( + fields: R + ): [Extract] extends [never] ? + Predicate<{ readonly [K in keyof R]: Predicate.In }> : + Refinement< + { readonly [K in keyof R]: R[K] extends Refinement.Any ? Refinement.In : Predicate.In }, + { readonly [K in keyof R]: R[K] extends Refinement.Any ? Refinement.Out : Predicate.In } + > +} = (>(fields: R) => { const keys = Object.keys(fields) - return (a) => { + return (a: Record) => { for (const key of keys) { if (!fields[key](a[key])) { return false @@ -710,7 +770,7 @@ export const struct = >>( } return true } -} +}) as any /** * Negates the result of a given predicate. diff --git a/packages/effect/src/Random.ts b/packages/effect/src/Random.ts index 26b524a4e2..233a6f0f69 100644 --- a/packages/effect/src/Random.ts +++ b/packages/effect/src/Random.ts @@ -1,11 +1,14 @@ /** * @since 2.0.0 */ +import type * as Array from "./Array.js" +import type * as Cause from "./Cause.js" import type * as Chunk from "./Chunk.js" import type * as Context from "./Context.js" import type * as Effect from "./Effect.js" import * as defaultServices from "./internal/defaultServices.js" import * as internal from "./internal/random.js" +import type * as NonEmptyIterable from "./NonEmptyIterable.js" /** * @since 2.0.0 @@ -103,6 +106,27 @@ export const nextIntBetween: (min: number, max: number) => Effect.Effect */ export const shuffle: (elements: Iterable) => Effect.Effect> = defaultServices.shuffle +/** + * Get a random element from an iterable. + * + * @example + * import { Effect, Random } from "effect" + * + * Effect.gen(function* () { + * const randomItem = yield* Random.choice([1, 2, 3]) + * console.log(randomItem) + * }) + * + * @since 3.6.0 + * @category constructors + */ +export const choice: >( + elements: Self +) => Self extends NonEmptyIterable.NonEmptyIterable ? Effect.Effect + : Self extends Array.NonEmptyReadonlyArray ? Effect.Effect + : Self extends Iterable ? Effect.Effect + : never = defaultServices.choice + /** * Retreives the `Random` service from the context and uses it to run the * specified workflow. diff --git a/packages/effect/src/Stream.ts b/packages/effect/src/Stream.ts index 6b695e26bc..8704158f82 100644 --- a/packages/effect/src/Stream.ts +++ b/packages/effect/src/Stream.ts @@ -373,6 +373,46 @@ export const asyncEffect: ( } | undefined ) => Stream = internal.asyncEffect +/** + * Creates a stream from an external push-based resource. + * + * You can use the `emit` helper to emit values to the stream. The `emit` helper + * returns a boolean indicating whether the value was emitted or not. + * + * You can also use the `emit` helper to signal the end of the stream by + * using apis such as `emit.end` or `emit.fail`. + * + * By default it uses an "unbounded" buffer size. + * You can customize the buffer size and strategy by passing an object as the + * second argument with the `bufferSize` and `strategy` fields. + * + * @example + * import { Effect, Stream } from "effect" + * + * Stream.asyncPush((emit) => + * Effect.acquireRelease( + * Effect.gen(function*() { + * yield* Effect.log("subscribing") + * return setInterval(() => emit.single("tick"), 1000) + * }), + * (handle) => + * Effect.gen(function*() { + * yield* Effect.log("unsubscribing") + * clearInterval(handle) + * }) + * ), { bufferSize: 16, strategy: "dropping" }) + * + * @since 3.6.0 + * @category constructors + */ +export const asyncPush: ( + register: (emit: Emit.EmitOpsPush) => Effect.Effect, + options?: { readonly bufferSize: "unbounded" } | { + readonly bufferSize?: number | undefined + readonly strategy?: "dropping" | "sliding" | undefined + } | undefined +) => Stream> = internal.asyncPush + /** * Creates a stream from an asynchronous callback that can be called multiple * times. The registration of the callback itself returns an a scoped @@ -2887,6 +2927,38 @@ export const mkString: (self: Stream) => Effect.Effect = internal.never +/** + * Adds an effect to be executed at the end of the stream. + * + * @example + * import { Console, Effect, Stream } from "effect" + * + * const stream = Stream.make(1, 2, 3).pipe( + * Stream.map((n) => n * 2), + * Stream.tap((n) => Console.log(`after mapping: ${n}`)), + * Stream.onEnd(Console.log("Stream ended")) + * ) + * + * Effect.runPromise(Stream.runCollect(stream)).then(console.log) + * // after mapping: 2 + * // after mapping: 4 + * // after mapping: 6 + * // Stream ended + * // { _id: 'Chunk', values: [ 2, 4, 6 ] } + * + * @since 3.6.0 + * @category sequencing + */ +export const onEnd: { + <_, E2, R2>( + effect: Effect.Effect<_, E2, R2> + ): (self: Stream) => Stream + ( + self: Stream, + effect: Effect.Effect<_, E2, R2> + ): Stream +} = internal.onEnd + /** * Runs the specified effect if this stream fails, providing the error to the * effect if it exists. @@ -2918,6 +2990,38 @@ export const onDone: { (self: Stream, cleanup: () => Effect.Effect): Stream } = internal.onDone +/** + * Adds an effect to be executed at the start of the stream. + * + * @example + * import { Console, Effect, Stream } from "effect" + * + * const stream = Stream.make(1, 2, 3).pipe( + * Stream.onStart(Console.log("Stream started")), + * Stream.map((n) => n * 2), + * Stream.tap((n) => Console.log(`after mapping: ${n}`)) + * ) + * + * // Effect.runPromise(Stream.runCollect(stream)).then(console.log) + * // Stream started + * // after mapping: 2 + * // after mapping: 4 + * // after mapping: 6 + * // { _id: 'Chunk', values: [ 2, 4, 6 ] } + * + * @since 3.6.0 + * @category sequencing + */ +export const onStart: { + <_, E2, R2>( + effect: Effect.Effect<_, E2, R2> + ): (self: Stream) => Stream + ( + self: Stream, + effect: Effect.Effect<_, E2, R2> + ): Stream +} = internal.onStart + /** * Translates any failure into a stream termination, making the stream * infallible and all failures unchecked. @@ -5923,5 +6027,6 @@ export const fromEventListener: ( readonly capture?: boolean readonly passive?: boolean readonly once?: boolean + readonly bufferSize?: number | "unbounded" | undefined } | undefined ) => Stream = internal.fromEventListener diff --git a/packages/effect/src/StreamEmit.ts b/packages/effect/src/StreamEmit.ts index 4c6a1884c5..fdb935d1e4 100644 --- a/packages/effect/src/StreamEmit.ts +++ b/packages/effect/src/StreamEmit.ts @@ -81,3 +81,56 @@ export interface EmitOps { */ single(value: A): Promise } + +/** + * @since 3.6.0 + * @category models + */ +export interface EmitOpsPush { + /** + * Emits a chunk containing the specified values. + */ + chunk(chunk: Chunk.Chunk): boolean + + /** + * Emits a chunk containing the specified values. + */ + array(chunk: ReadonlyArray): boolean + + /** + * Terminates with a cause that dies with the specified defect. + */ + die(defect: Err): void + + /** + * Terminates with a cause that dies with a `Throwable` with the specified + * message. + */ + dieMessage(message: string): void + + /** + * Either emits the specified value if this `Exit` is a `Success` or else + * terminates with the specified cause if this `Exit` is a `Failure`. + */ + done(exit: Exit.Exit): void + + /** + * Terminates with an end of stream signal. + */ + end(): void + + /** + * Terminates with the specified error. + */ + fail(error: E): void + + /** + * Terminates the stream with the specified cause. + */ + halt(cause: Cause.Cause): void + + /** + * Emits a chunk containing the specified value. + */ + single(value: A): boolean +} diff --git a/packages/effect/src/Struct.ts b/packages/effect/src/Struct.ts index 862cd34e1f..fdf8da818c 100644 --- a/packages/effect/src/Struct.ts +++ b/packages/effect/src/Struct.ts @@ -179,3 +179,25 @@ export const evolve: { export const get = (key: K) => (s: S): MatchRecord => s[key] + +/** + * Retrieves the object keys that are strings in a typed manner + * + * @example + * import { Struct } from "effect" + * + * const symbol: unique symbol = Symbol() + * + * const value = { + * a: 1, + * b: 2, + * [symbol]: 3 + * } + * + * const keys: Array<"a" | "b"> = Struct.keys(value) + * + * assert.deepStrictEqual(keys, ["a", "b"]) + * + * @since 3.6.0 + */ +export const keys = (o: T): Array<(keyof T) & string> => Object.keys(o) as Array<(keyof T) & string> diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index eb5ce51c2d..121a8db705 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -187,6 +187,11 @@ export * as Cron from "./Cron.js" */ export * as Data from "./Data.js" +/** + * @since 3.6.0 + */ +export * as DateTime from "./DateTime.js" + /** * @since 2.0.0 */ diff --git a/packages/effect/src/internal/configProvider.ts b/packages/effect/src/internal/configProvider.ts index e30a3cb010..fe8a0f9227 100644 --- a/packages/effect/src/internal/configProvider.ts +++ b/packages/effect/src/internal/configProvider.ts @@ -68,7 +68,7 @@ export const makeFlat = ( path: ReadonlyArray, config: Config.Config.Primitive, split: boolean - ) => Effect.Effect, ConfigError.ConfigError> + ) => Effect.Effect, ConfigError.ConfigError> readonly enumerateChildren: ( path: ReadonlyArray ) => Effect.Effect, ConfigError.ConfigError> @@ -114,7 +114,7 @@ export const fromEnv = ( path: ReadonlyArray, primitive: Config.Config.Primitive, split = true - ): Effect.Effect, ConfigError.ConfigError> => { + ): Effect.Effect, ConfigError.ConfigError> => { const pathString = makePathString(path) const current = getEnv() const valueOpt = pathString in current ? Option.some(current[pathString]!) : Option.none() @@ -165,7 +165,7 @@ export const fromMap = ( path: ReadonlyArray, primitive: Config.Config.Primitive, split = true - ): Effect.Effect, ConfigError.ConfigError> => { + ): Effect.Effect, ConfigError.ConfigError> => { const pathString = makePathString(path) const valueOpt = mapWithIndexSplit.has(pathString) ? Option.some(mapWithIndexSplit.get(pathString)!) : @@ -240,20 +240,20 @@ const fromFlatLoop = ( prefix: ReadonlyArray, config: Config.Config, split: boolean -): Effect.Effect, ConfigError.ConfigError> => { +): Effect.Effect, ConfigError.ConfigError> => { const op = config as _config.ConfigPrimitive switch (op._tag) { case OpCodes.OP_CONSTANT: { - return core.succeed(Arr.of(op.value)) as Effect.Effect, ConfigError.ConfigError> + return core.succeed(Arr.of(op.value)) as Effect.Effect, ConfigError.ConfigError> } case OpCodes.OP_DESCRIBED: { return core.suspend( () => fromFlatLoop(flat, prefix, op.config, split) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> } case OpCodes.OP_FAIL: { return core.fail(configError.MissingData(prefix, op.message)) as Effect.Effect< - ReadonlyArray, + Array, ConfigError.ConfigError > } @@ -269,11 +269,11 @@ const fromFlatLoop = ( } return core.fail(error1) }) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> } case OpCodes.OP_LAZY: { return core.suspend(() => fromFlatLoop(flat, prefix, op.config(), split)) as Effect.Effect< - ReadonlyArray, + Array, ConfigError.ConfigError > } @@ -290,7 +290,7 @@ const fromFlatLoop = ( ) ) ) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> } case OpCodes.OP_NESTED: { return core.suspend(() => @@ -300,7 +300,7 @@ const fromFlatLoop = ( op.config, split ) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> } case OpCodes.OP_PRIMITIVE: { return pipe( @@ -317,7 +317,7 @@ const fromFlatLoop = ( }) ) ) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> } case OpCodes.OP_SEQUENCE: { return pipe( @@ -330,7 +330,7 @@ const fromFlatLoop = ( if (indices.length === 0) { return core.suspend(() => core.map(fromFlatLoop(flat, patchedPrefix, op.config, true), Arr.of) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> } return pipe( core.forEachSequential( @@ -344,7 +344,7 @@ const fromFlatLoop = ( } return Arr.of(flattened) }) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> }) ) ) @@ -382,7 +382,7 @@ const fromFlatLoop = ( ) ) ) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> } case OpCodes.OP_ZIP_WITH: { return core.suspend(() => @@ -430,7 +430,7 @@ const fromFlatLoop = ( ) ) ) - ) as unknown as Effect.Effect, ConfigError.ConfigError> + ) as unknown as Effect.Effect, ConfigError.ConfigError> } } } @@ -592,7 +592,7 @@ export const within = dual< return orElse(nest, () => self) }) -const splitPathString = (text: string, delim: string): ReadonlyArray => { +const splitPathString = (text: string, delim: string): Array => { const split = text.split(new RegExp(`\\s*${regexp.escape(delim)}\\s*`)) return split } @@ -603,7 +603,7 @@ const parsePrimitive = ( primitive: Config.Config.Primitive, delimiter: string, split: boolean -): Effect.Effect, ConfigError.ConfigError> => { +): Effect.Effect, ConfigError.ConfigError> => { if (!split) { return pipe( primitive.parse(text), @@ -620,11 +620,11 @@ const parsePrimitive = ( ) } -const transpose = (array: ReadonlyArray>): ReadonlyArray> => { +const transpose = (array: ReadonlyArray>): Array> => { return Object.keys(array[0]).map((column) => array.map((row) => row[column as any])) } -const indicesFrom = (quotedIndices: HashSet.HashSet): Effect.Effect> => +const indicesFrom = (quotedIndices: HashSet.HashSet): Effect.Effect> => pipe( core.forEachSequential(quotedIndices, parseQuotedIndex), core.mapBoth({ diff --git a/packages/effect/src/internal/core.ts b/packages/effect/src/internal/core.ts index d0ae961aca..9b2815eee8 100644 --- a/packages/effect/src/internal/core.ts +++ b/packages/effect/src/internal/core.ts @@ -1250,6 +1250,12 @@ export const tap = dual< ) => [X] extends [Effect.Effect] ? Effect.Effect : [X] extends [PromiseLike] ? Effect.Effect : Effect.Effect + ( + f: (a: NoInfer) => Effect.Effect, + options: { onlyEffect: true } + ): ( + self: Effect.Effect + ) => Effect.Effect ( f: NotFunction ): ( @@ -1257,6 +1263,12 @@ export const tap = dual< ) => [X] extends [Effect.Effect] ? Effect.Effect : [X] extends [PromiseLike] ? Effect.Effect : Effect.Effect + ( + f: Effect.Effect, + options: { onlyEffect: true } + ): ( + self: Effect.Effect + ) => Effect.Effect }, { ( @@ -1265,25 +1277,38 @@ export const tap = dual< ): [X] extends [Effect.Effect] ? Effect.Effect : [X] extends [PromiseLike] ? Effect.Effect : Effect.Effect + ( + self: Effect.Effect, + f: (a: NoInfer) => Effect.Effect, + options: { onlyEffect: true } + ): Effect.Effect ( self: Effect.Effect, f: NotFunction ): [X] extends [Effect.Effect] ? Effect.Effect : [X] extends [PromiseLike] ? Effect.Effect : Effect.Effect + ( + self: Effect.Effect, + f: Effect.Effect, + options: { onlyEffect: true } + ): Effect.Effect } ->(2, (self, f) => - flatMap(self, (a) => { - const b = typeof f === "function" ? (f as any)(a) : f - if (isEffect(b)) { - return as(b, a) - } else if (isPromiseLike(b)) { - return async((resume) => { - b.then((_) => resume(succeed(a)), (e) => resume(fail(new UnknownException(e)))) - }) - } - return succeed(a) - })) +>( + (args) => args.length === 3 || args.length === 2 && !(isObject(args[1]) && "onlyEffect" in args[1]), + (self: Effect.Effect, f: X) => + flatMap(self, (a) => { + const b = typeof f === "function" ? (f as any)(a) : f + if (isEffect(b)) { + return as(b, a) + } else if (isPromiseLike(b)) { + return async((resume) => { + b.then((_) => resume(succeed(a)), (e) => resume(fail(new UnknownException(e)))) + }) + } + return succeed(a) + }) +) /* @internal */ export const transplant = ( diff --git a/packages/effect/src/internal/defaultServices.ts b/packages/effect/src/internal/defaultServices.ts index f8c6ade906..18f96fc4b5 100644 --- a/packages/effect/src/internal/defaultServices.ts +++ b/packages/effect/src/internal/defaultServices.ts @@ -1,3 +1,4 @@ +import * as Array from "../Array.js" import type * as Chunk from "../Chunk.js" import type * as Clock from "../Clock.js" import type * as Config from "../Config.js" @@ -133,6 +134,19 @@ export const nextIntBetween = (min: number, max: number): Effect.Effect export const shuffle = (elements: Iterable): Effect.Effect> => randomWith((random) => random.shuffle(elements)) +/** @internal */ +export const choice = >( + elements: Self +) => { + const array = Array.fromIterable(elements) + return core.map( + array.length === 0 + ? core.fail(new core.NoSuchElementException("Cannot select a random element from an empty array")) + : randomWith((random) => random.nextIntBetween(0, array.length)), + (i) => array[i] + ) as any +} + // circular with Tracer /** @internal */ diff --git a/packages/effect/src/internal/metric.ts b/packages/effect/src/internal/metric.ts index 43f0b2d9f9..3830e78338 100644 --- a/packages/effect/src/internal/metric.ts +++ b/packages/effect/src/internal/metric.ts @@ -525,9 +525,9 @@ export const zip = dual< ) /** @internal */ -export const unsafeSnapshot = (): ReadonlyArray => globalMetricRegistry.snapshot() +export const unsafeSnapshot = (): Array => globalMetricRegistry.snapshot() /** @internal */ -export const snapshot: Effect.Effect> = core.sync( +export const snapshot: Effect.Effect> = core.sync( unsafeSnapshot ) diff --git a/packages/effect/src/internal/metric/registry.ts b/packages/effect/src/internal/metric/registry.ts index beef571737..4417acafbc 100644 --- a/packages/effect/src/internal/metric/registry.ts +++ b/packages/effect/src/internal/metric/registry.ts @@ -27,7 +27,7 @@ class MetricRegistryImpl implements MetricRegistry.MetricRegistry { MetricHook.MetricHook.Root >() - snapshot(): ReadonlyArray { + snapshot(): Array { const result: Array = [] for (const [key, hook] of this.map) { result.push(metricPair.unsafeMake(key, hook.get())) diff --git a/packages/effect/src/internal/stream.ts b/packages/effect/src/internal/stream.ts index 402408abae..fe02cea53f 100644 --- a/packages/effect/src/internal/stream.ts +++ b/packages/effect/src/internal/stream.ts @@ -10,6 +10,7 @@ import * as Either from "../Either.js" import * as Equal from "../Equal.js" import * as Exit from "../Exit.js" import * as Fiber from "../Fiber.js" +import * as FiberRef from "../FiberRef.js" import type { LazyArg } from "../Function.js" import { constTrue, dual, identity, pipe } from "../Function.js" import * as Layer from "../Layer.js" @@ -597,6 +598,51 @@ export const asyncEffect = ( fromChannel ) +const queueFromBufferOptionsPush = ( + options?: { readonly bufferSize: "unbounded" } | { + readonly bufferSize?: number | undefined + readonly strategy?: "dropping" | "sliding" | undefined + } | undefined +): Effect.Effect | Exit.Exit>> => { + if (options?.bufferSize === "unbounded" || (options?.bufferSize === undefined && options?.strategy === undefined)) { + return Queue.unbounded() + } + switch (options?.strategy) { + case "sliding": + return Queue.sliding(options.bufferSize ?? 16) + default: + return Queue.dropping(options?.bufferSize ?? 16) + } +} + +/** @internal */ +export const asyncPush = ( + register: (emit: Emit.EmitOpsPush) => Effect.Effect, + options?: { + readonly bufferSize: "unbounded" + } | { + readonly bufferSize?: number | undefined + readonly strategy?: "dropping" | "sliding" | undefined + } | undefined +): Stream.Stream> => + Effect.acquireRelease( + queueFromBufferOptionsPush(options), + Queue.shutdown + ).pipe( + Effect.tap((queue) => + FiberRef.getWith(FiberRef.currentScheduler, (scheduler) => register(emit.makePush(queue, scheduler))) + ), + Effect.map((queue) => { + const loop: Channel.Channel, unknown, E> = core.flatMap(Queue.take(queue), (item) => + Exit.isExit(item) + ? Exit.isSuccess(item) ? core.void : core.failCause(item.cause) + : channel.zipRight(core.write(Chunk.unsafeFromArray(item)), loop)) + return loop + }), + channel.unwrapScoped, + fromChannel + ) + /** @internal */ export const asyncScoped = ( register: (emit: Emit.Emit) => Effect.Effect, @@ -4116,6 +4162,23 @@ export const mkString = (self: Stream.Stream): Effect.Effect /** @internal */ export const never: Stream.Stream = fromEffect(Effect.never) +/** @internal */ +export const onEnd: { + <_, E2, R2>( + effect: Effect.Effect<_, E2, R2> + ): (self: Stream.Stream) => Stream.Stream + ( + self: Stream.Stream, + effect: Effect.Effect<_, E2, R2> + ): Stream.Stream +} = dual( + 2, + ( + self: Stream.Stream, + effect: Effect.Effect<_, E2, R2> + ): Stream.Stream => concat(self, drain(fromEffect(effect))) +) + /** @internal */ export const onError = dual< ( @@ -4154,6 +4217,23 @@ export const onDone = dual< ) ) +/** @internal */ +export const onStart: { + <_, E2, R2>( + effect: Effect.Effect<_, E2, R2> + ): (self: Stream.Stream) => Stream.Stream + ( + self: Stream.Stream, + effect: Effect.Effect<_, E2, R2> + ): Stream.Stream +} = dual( + 2, + ( + self: Stream.Stream, + effect: Effect.Effect<_, E2, R2> + ): Stream.Stream => unwrap(Effect.as(effect, self)) +) + /** @internal */ export const orDie = (self: Stream.Stream): Stream.Stream => pipe(self, orDieWith(identity)) @@ -8324,23 +8404,11 @@ export const fromEventListener = ( readonly capture?: boolean readonly passive?: boolean readonly once?: boolean + readonly bufferSize?: number | "unbounded" | undefined } | undefined ): Stream.Stream => - _async((emit) => { - let batch: Array = [] - let taskRunning = false - function cb(e: A) { - batch.push(e) - if (!taskRunning) { - taskRunning = true - queueMicrotask(() => { - const events = batch - batch = [] - taskRunning = false - emit.chunk(Chunk.unsafeFromArray(events)) - }) - } - } - target.addEventListener(type, cb as any, options) - return Effect.sync(() => target.removeEventListener(type, cb, options)) - }, "unbounded") + asyncPush((emit) => + Effect.acquireRelease( + Effect.sync(() => target.addEventListener(type, emit.single as any, options)), + () => Effect.sync(() => target.removeEventListener(type, emit.single, options)) + ), { bufferSize: typeof options === "object" ? options.bufferSize : undefined }) diff --git a/packages/effect/src/internal/stream/emit.ts b/packages/effect/src/internal/stream/emit.ts index 613e7d49b1..7dd1623d43 100644 --- a/packages/effect/src/internal/stream/emit.ts +++ b/packages/effect/src/internal/stream/emit.ts @@ -4,6 +4,8 @@ import * as Effect from "../../Effect.js" import * as Exit from "../../Exit.js" import { pipe } from "../../Function.js" import * as Option from "../../Option.js" +import type * as Queue from "../../Queue.js" +import type * as Scheduler from "../../Scheduler.js" import type * as Emit from "../../StreamEmit.js" /** @internal */ @@ -44,3 +46,78 @@ export const make = ( } return Object.assign(emit, ops) } + +/** @internal */ +export const makePush = ( + queue: Queue.Queue | Exit.Exit>, + scheduler: Scheduler.Scheduler +): Emit.EmitOpsPush => { + let finished = false + let buffer: Array = [] + let running = false + function array(items: ReadonlyArray) { + if (finished) return false + if (items.length <= 50_000) { + buffer.push.apply(buffer, items as Array) + } else { + for (let i = 0; i < items.length; i++) { + buffer.push(items[0]) + } + } + if (!running) { + running = true + scheduler.scheduleTask(flush, 0) + } + return true + } + function flush() { + running = false + if (buffer.length > 0) { + queue.unsafeOffer(buffer) + buffer = [] + } + } + function done(exit: Exit.Exit) { + if (finished) return + finished = true + if (exit._tag === "Success") { + buffer.push(exit.value) + } + flush() + queue.unsafeOffer(exit._tag === "Success" ? Exit.void : exit) + } + return { + single(value: A) { + if (finished) return false + buffer.push(value) + if (!running) { + running = true + scheduler.scheduleTask(flush, 0) + } + return true + }, + array, + chunk(chunk) { + return array(Chunk.toReadonlyArray(chunk)) + }, + done, + end() { + if (finished) return + finished = true + flush() + queue.unsafeOffer(Exit.void) + }, + halt(cause: Cause.Cause) { + return done(Exit.failCause(cause)) + }, + fail(error: E) { + return done(Exit.fail(error)) + }, + die(defect: Err): void { + return done(Exit.die(defect)) + }, + dieMessage(message: string): void { + return done(Exit.die(new Error(message))) + } + } +} diff --git a/packages/effect/test/DateTime.test.ts b/packages/effect/test/DateTime.test.ts new file mode 100644 index 0000000000..e8454bb87d --- /dev/null +++ b/packages/effect/test/DateTime.test.ts @@ -0,0 +1,387 @@ +import { DateTime, Duration, Effect, Either, Option, TestClock } from "effect" +import { assert, describe, it } from "./utils/extend.js" + +const setTo2024NZ = TestClock.setTime(new Date("2023-12-31T11:00:00.000Z").getTime()) + +describe("DateTime", () => { + describe("mutate", () => { + it.effect("should mutate the date", () => + Effect.gen(function*() { + const now = yield* DateTime.now + const tomorrow = DateTime.mutate(now, (date) => { + date.setUTCDate(date.getUTCDate() + 1) + }) + const diff = DateTime.distanceDurationEither(now, tomorrow) + assert.deepStrictEqual(diff, Either.right(Duration.decode("1 day"))) + })) + + it.effect("correctly preserves the time zone", () => + Effect.gen(function*() { + yield* setTo2024NZ + const now = yield* DateTime.nowInCurrentZone.pipe( + DateTime.withCurrentZoneNamed("Pacific/Auckland") + ) + const future = DateTime.mutate(now, (date) => { + date.setUTCMonth(date.getUTCMonth() + 6) + }) + assert.strictEqual(DateTime.toDateUtc(future).toISOString(), "2024-06-30T12:00:00.000Z") + assert.strictEqual(DateTime.toDate(future).toISOString(), "2024-07-01T00:00:00.000Z") + const plusOne = DateTime.mutate(future, (date) => { + date.setUTCDate(date.getUTCDate() + 1) + }) + assert.strictEqual(DateTime.toDateUtc(plusOne).toISOString(), "2024-07-01T12:00:00.000Z") + assert.strictEqual(DateTime.toDate(plusOne).toISOString(), "2024-07-02T00:00:00.000Z") + })) + }) + + describe("add", () => { + it.effect("utc", () => + Effect.gen(function*() { + const now = yield* DateTime.now + const tomorrow = DateTime.add(now, { days: 1 }) + const diff = DateTime.distanceDurationEither(now, tomorrow) + assert.deepStrictEqual(diff, Either.right(Duration.decode("1 day"))) + })) + + it("to month with less days", () => { + const jan = DateTime.unsafeMake({ year: 2023, month: 1, day: 31 }) + let feb = DateTime.add(jan, { months: 1 }) + assert.strictEqual(feb.toJSON(), "2023-02-28T00:00:00.000Z") + + const mar = DateTime.unsafeMake({ year: 2023, month: 3, day: 31 }) + feb = DateTime.subtract(mar, { months: 1 }) + assert.strictEqual(feb.toJSON(), "2023-02-28T00:00:00.000Z") + }) + + it.effect("correctly preserves the time zone", () => + Effect.gen(function*() { + yield* setTo2024NZ + const now = yield* DateTime.nowInCurrentZone.pipe( + DateTime.withCurrentZoneNamed("Pacific/Auckland") + ) + const future = DateTime.add(now, { months: 6 }) + assert.strictEqual(DateTime.toDateUtc(future).toISOString(), "2024-06-30T12:00:00.000Z") + assert.strictEqual(DateTime.toDate(future).toISOString(), "2024-07-01T00:00:00.000Z") + const plusOne = DateTime.add(future, { days: 1 }) + assert.strictEqual(DateTime.toDateUtc(plusOne).toISOString(), "2024-07-01T12:00:00.000Z") + assert.strictEqual(DateTime.toDate(plusOne).toISOString(), "2024-07-02T00:00:00.000Z") + const minusOne = DateTime.subtract(plusOne, { days: 1 }) + assert.strictEqual(DateTime.toDateUtc(minusOne).toISOString(), "2024-06-30T12:00:00.000Z") + assert.strictEqual(DateTime.toDate(minusOne).toISOString(), "2024-07-01T00:00:00.000Z") + })) + + it.effect("leap years", () => + Effect.gen(function*() { + yield* setTo2024NZ + const now = yield* DateTime.make({ year: 2024, month: 2, day: 29 }) + const future = DateTime.add(now, { years: 1 }) + assert.strictEqual(DateTime.formatIso(future), "2025-02-28T00:00:00.000Z") + })) + }) + + describe("endOf", () => { + it("month", () => { + const mar = DateTime.unsafeMake("2024-03-15T12:00:00.000Z") + const end = DateTime.endOf(mar, "month") + assert.strictEqual(end.toJSON(), "2024-03-31T23:59:59.999Z") + }) + + it("feb leap year", () => { + const feb = DateTime.unsafeMake("2024-02-15T12:00:00.000Z") + const end = DateTime.endOf(feb, "month") + assert.strictEqual(end.toJSON(), "2024-02-29T23:59:59.999Z") + }) + + it("week", () => { + const start = DateTime.unsafeMake("2024-03-15T12:00:00.000Z") + const end = DateTime.endOf(start, "week") + assert.strictEqual(end.toJSON(), "2024-03-16T23:59:59.999Z") + assert.strictEqual(DateTime.getPartUtc(end, "weekDay"), 6) + }) + + it("week last day", () => { + const start = DateTime.unsafeMake("2024-03-16T12:00:00.000Z") + const end = DateTime.endOf(start, "week") + assert.strictEqual(end.toJSON(), "2024-03-16T23:59:59.999Z") + }) + + it("week with options", () => { + const start = DateTime.unsafeMake("2024-03-15T12:00:00.000Z") + const end = DateTime.endOf(start, "week", { + weekStartsOn: 1 + }) + assert.strictEqual(end.toJSON(), "2024-03-17T23:59:59.999Z") + }) + + it.effect("correctly preserves the time zone", () => + Effect.gen(function*() { + yield* setTo2024NZ + const now = yield* DateTime.nowInCurrentZone.pipe( + DateTime.withCurrentZoneNamed("Pacific/Auckland") + ) + const future = DateTime.endOf(now, "month") + assert.strictEqual(DateTime.toDateUtc(future).toISOString(), "2024-01-31T10:59:59.999Z") + assert.strictEqual(DateTime.toDate(future).toISOString(), "2024-01-31T23:59:59.999Z") + })) + }) + + describe("startOf", () => { + it("month", () => { + const mar = DateTime.unsafeMake("2024-03-15T12:00:00.000Z") + const end = DateTime.startOf(mar, "month") + assert.strictEqual(end.toJSON(), "2024-03-01T00:00:00.000Z") + }) + + it("month duplicated", () => { + const mar = DateTime.unsafeMake("2024-03-15T12:00:00.000Z") + const end = DateTime.startOf(mar, "month").pipe( + DateTime.startOf("month") + ) + assert.strictEqual(end.toJSON(), "2024-03-01T00:00:00.000Z") + }) + + it("feb leap year", () => { + const feb = DateTime.unsafeMake("2024-02-15T12:00:00.000Z") + const end = DateTime.startOf(feb, "month") + assert.strictEqual(end.toJSON(), "2024-02-01T00:00:00.000Z") + }) + + it("week", () => { + const start = DateTime.unsafeMake("2024-03-15T12:00:00.000Z") + const end = DateTime.startOf(start, "week") + assert.strictEqual(end.toJSON(), "2024-03-10T00:00:00.000Z") + assert.strictEqual(DateTime.getPartUtc(end, "weekDay"), 0) + }) + + it("week first day", () => { + const start = DateTime.unsafeMake("2024-03-10T12:00:00.000Z") + const end = DateTime.startOf(start, "week") + assert.strictEqual(end.toJSON(), "2024-03-10T00:00:00.000Z") + }) + + it("week with options", () => { + const start = DateTime.unsafeMake("2024-03-15T12:00:00.000Z") + const end = DateTime.startOf(start, "week", { + weekStartsOn: 1 + }) + assert.strictEqual(end.toJSON(), "2024-03-11T00:00:00.000Z") + }) + }) + + describe("nearest", () => { + it("month up", () => { + const mar = DateTime.unsafeMake("2024-03-16T12:00:00.000Z") + const end = DateTime.nearest(mar, "month") + assert.strictEqual(end.toJSON(), "2024-04-01T00:00:00.000Z") + }) + + it("month down", () => { + const mar = DateTime.unsafeMake("2024-03-16T11:00:00.000Z") + const end = DateTime.nearest(mar, "month") + assert.strictEqual(end.toJSON(), "2024-03-01T00:00:00.000Z") + }) + + it("second up", () => { + const mar = DateTime.unsafeMake("2024-03-20T12:00:00.500Z") + const end = DateTime.nearest(mar, "second") + assert.strictEqual(end.toJSON(), "2024-03-20T12:00:01.000Z") + }) + + it("second down", () => { + const mar = DateTime.unsafeMake("2024-03-20T12:00:00.400Z") + const end = DateTime.nearest(mar, "second") + assert.strictEqual(end.toJSON(), "2024-03-20T12:00:00.000Z") + }) + }) + + describe("format", () => { + it.effect("full", () => + Effect.gen(function*() { + const now = yield* DateTime.now + assert.strictEqual( + DateTime.format(now, { + dateStyle: "full", + timeStyle: "full" + }), + "Thursday, January 1, 1970 at 12:00:00 AM Coordinated Universal Time" + ) + })) + }) + + describe("formatUtc", () => { + it.effect("full", () => + Effect.gen(function*() { + const now = yield* DateTime.now + assert.strictEqual( + DateTime.formatUtc(now, { dateStyle: "full", timeStyle: "full" }), + "Thursday, January 1, 1970 at 12:00:00 AM Coordinated Universal Time" + ) + })) + }) + + describe("format zoned", () => { + it.effect("full", () => + Effect.gen(function*() { + const now = yield* DateTime.nowInCurrentZone.pipe( + DateTime.withCurrentZoneNamed("Pacific/Auckland") + ) + assert.strictEqual( + DateTime.format(now, { dateStyle: "full", timeStyle: "full" }), + "Thursday, January 1, 1970 at 12:00:00 PM New Zealand Standard Time" + ) + })) + + it.effect("long with offset", () => + Effect.gen(function*() { + const now = yield* DateTime.now + const formatted = now.pipe( + DateTime.setZoneOffset(10 * 60 * 60 * 1000), + DateTime.format({ dateStyle: "long", timeStyle: "short" }) + ) + assert.strictEqual(formatted, "January 1, 1970 at 10:00 AM") + })) + }) + + describe("fromParts", () => { + it("partial", () => { + const date = DateTime.unsafeMake({ + year: 2024, + month: 12, + day: 25 + }) + assert.strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z") + }) + + it("month is set correctly", () => { + const date = DateTime.unsafeMake({ year: 2024 }) + assert.strictEqual(date.toJSON(), "2024-01-01T00:00:00.000Z") + }) + }) + + describe("setPartsUtc", () => { + it("partial", () => { + const date = DateTime.unsafeMake({ + year: 2024, + month: 12, + day: 25 + }) + assert.strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z") + + const updated = DateTime.setPartsUtc(date, { + year: 2023, + month: 1 + }) + assert.strictEqual(updated.toJSON(), "2023-01-25T00:00:00.000Z") + }) + + it("ignores time zones", () => { + const date = DateTime.unsafeMake({ + year: 2024, + month: 12, + day: 25 + }).pipe(DateTime.unsafeSetZoneNamed("Pacific/Auckland")) + assert.strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z") + + const updated = DateTime.setPartsUtc(date, { + year: 2023, + month: 1 + }) + assert.strictEqual(updated.toJSON(), "2023-01-25T00:00:00.000Z") + }) + }) + + describe("setParts", () => { + it("partial", () => { + const date = DateTime.unsafeMake({ + year: 2024, + month: 12, + day: 25 + }) + assert.strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z") + + const updated = DateTime.setParts(date, { + year: 2023, + month: 1 + }) + assert.strictEqual(updated.toJSON(), "2023-01-25T00:00:00.000Z") + }) + + it("accounts for time zone", () => { + const date = DateTime.unsafeMake({ + year: 2024, + month: 12, + day: 25 + }).pipe(DateTime.unsafeSetZoneNamed("Pacific/Auckland")) + assert.strictEqual(date.toJSON(), "2024-12-25T00:00:00.000Z") + + const updated = DateTime.setParts(date, { + year: 2023, + month: 6, + hours: 12 + }) + assert.strictEqual(updated.toJSON(), "2023-06-25T00:00:00.000Z") + }) + }) + + describe("formatIso", () => { + it("full", () => { + const now = DateTime.unsafeMake("2024-03-15T12:00:00.000Z") + assert.strictEqual(DateTime.formatIso(now), "2024-03-15T12:00:00.000Z") + }) + }) + + describe("formatIsoOffset", () => { + it.effect("correctly adds offset", () => + Effect.gen(function*() { + const now = yield* DateTime.nowInCurrentZone.pipe( + DateTime.withCurrentZoneNamed("Pacific/Auckland") + ) + assert.strictEqual(DateTime.formatIsoOffset(now), "1970-01-01T12:00:00.000+12:00") + })) + }) + + describe("layerCurrentZoneNamed", () => { + it.effect("correctly adds offset", () => + Effect.gen(function*() { + const now = yield* DateTime.nowInCurrentZone + assert.strictEqual(DateTime.formatIsoOffset(now), "1970-01-01T12:00:00.000+12:00") + }).pipe( + Effect.provide(DateTime.layerCurrentZoneNamed("Pacific/Auckland")) + )) + }) + + describe("removeTime", () => { + it("removes time", () => { + const dt = DateTime.unsafeMakeZoned("2024-01-01T01:00:00Z", { + timeZone: "Pacific/Auckland", + adjustForTimeZone: true + }).pipe(DateTime.removeTime) + assert.strictEqual(dt.toJSON(), "2024-01-01T00:00:00.000Z") + }) + }) + + describe("makeZonedFromString", () => { + it.effect("parses time + zone", () => + Effect.gen(function*() { + const dt = yield* DateTime.makeZonedFromString("2024-07-21T20:12:34.112546348+12:00[Pacific/Auckland]") + assert.strictEqual(dt.toJSON(), "2024-07-21T08:12:34.112Z") + })) + + it.effect("only offset", () => + Effect.gen(function*() { + const dt = yield* DateTime.makeZonedFromString("2024-07-21T20:12:34.112546348+12:00") + assert.strictEqual(dt.zone._tag, "Offset") + assert.strictEqual(dt.toJSON(), "2024-07-21T08:12:34.112Z") + })) + + it.effect("roundtrip", () => + Effect.gen(function*() { + const dt = yield* DateTime.makeZonedFromString("2024-07-21T20:12:34.112546348+12:00[Pacific/Auckland]").pipe( + Option.map(DateTime.formatIsoZoned), + Option.flatMap(DateTime.makeZonedFromString) + ) + assert.deepStrictEqual(dt.zone, DateTime.zoneUnsafeMakeNamed("Pacific/Auckland")) + assert.strictEqual(dt.toJSON(), "2024-07-21T08:12:34.112Z") + })) + }) +}) diff --git a/packages/effect/test/Effect/sequencing.test.ts b/packages/effect/test/Effect/sequencing.test.ts index 8207e5aa2b..1aeb59a094 100644 --- a/packages/effect/test/Effect/sequencing.test.ts +++ b/packages/effect/test/Effect/sequencing.test.ts @@ -36,27 +36,33 @@ describe("Effect", () => { assert.strictEqual(yield* $(a9), "ok") })) it.effect("tap", () => - Effect.gen(function*($) { + Effect.gen(function*() { const a0 = Effect.tap(Effect.succeed(0), Effect.succeed(1)) const a1 = Effect.succeed(0).pipe(Effect.tap(Effect.succeed(1))) - const a2 = Effect.tap(Effect.succeed(0), (n) => Effect.succeed(n + 1)) - const a3 = Effect.succeed(0).pipe(Effect.tap((n) => Effect.succeed(n + 1))) - const a4 = Effect.succeed(0).pipe(Effect.tap("ok")) - const a5 = Effect.succeed(0).pipe(Effect.tap(() => "ok")) - const a6 = Effect.tap(Effect.succeed(0), () => "ok") - const a7 = Effect.tap(Effect.succeed(0), "ok") - const a8 = Effect.tap(Effect.succeed(0), () => Promise.resolve("ok")) - const a9 = Effect.tap(Effect.succeed(0), Promise.resolve("ok")) - assert.strictEqual(yield* $(a0), 0) - assert.strictEqual(yield* $(a1), 0) - assert.strictEqual(yield* $(a2), 0) - assert.strictEqual(yield* $(a3), 0) - assert.strictEqual(yield* $(a4), 0) - assert.strictEqual(yield* $(a5), 0) - assert.strictEqual(yield* $(a6), 0) - assert.strictEqual(yield* $(a7), 0) - assert.strictEqual(yield* $(a8), 0) - assert.strictEqual(yield* $(a9), 0) + const a2 = Effect.succeed(0).pipe(Effect.tap(Effect.succeed(1), { onlyEffect: true })) + const a3 = Effect.tap(Effect.succeed(0), (n) => Effect.succeed(n + 1)) + const a4 = Effect.tap(Effect.succeed(0), (n) => Effect.succeed(n + 1), { onlyEffect: true }) + const a5 = Effect.succeed(0).pipe(Effect.tap((n) => Effect.succeed(n + 1))) + const a6 = Effect.succeed(0).pipe(Effect.tap((n) => Effect.succeed(n + 1), { onlyEffect: true })) + const a7 = Effect.succeed(0).pipe(Effect.tap("ok")) + const a8 = Effect.succeed(0).pipe(Effect.tap(() => "ok")) + const a9 = Effect.tap(Effect.succeed(0), () => "ok") + const a10 = Effect.tap(Effect.succeed(0), "ok") + const a11 = Effect.tap(Effect.succeed(0), () => Promise.resolve("ok")) + const a12 = Effect.tap(Effect.succeed(0), Promise.resolve("ok")) + assert.strictEqual(yield* a0, 0) + assert.strictEqual(yield* a1, 0) + assert.strictEqual(yield* a2, 0) + assert.strictEqual(yield* a3, 0) + assert.strictEqual(yield* a4, 0) + assert.strictEqual(yield* a5, 0) + assert.strictEqual(yield* a6, 0) + assert.strictEqual(yield* a7, 0) + assert.strictEqual(yield* a8, 0) + assert.strictEqual(yield* a9, 0) + assert.strictEqual(yield* a10, 0) + assert.strictEqual(yield* a11, 0) + assert.strictEqual(yield* a12, 0) })) it.effect("flattens nested effects", () => Effect.gen(function*($) { diff --git a/packages/effect/test/Random.test.ts b/packages/effect/test/Random.test.ts index 5bcf690a09..6d4886c10b 100644 --- a/packages/effect/test/Random.test.ts +++ b/packages/effect/test/Random.test.ts @@ -1,12 +1,12 @@ -import { Array, Chunk, Data, Effect, Random } from "effect" -import * as it from "effect/test/utils/extend" +import { Array, Cause, Chunk, Data, Effect, Random } from "effect" +import { expect, it } from "effect/test/utils/extend" import { assert, describe } from "vitest" describe("Random", () => { it.effect("shuffle", () => - Effect.gen(function*($) { + Effect.gen(function*() { const start = Array.range(0, 100) - const end = yield* $(Random.shuffle(start)) + const end = yield* Random.shuffle(start) assert.isTrue(Chunk.every(end, (n) => n !== undefined)) assert.deepStrictEqual(start.sort(), Array.fromIterable(end).sort()) }).pipe(Effect.repeatN(100))) @@ -25,4 +25,15 @@ describe("Random", () => { assert.strictEqual(n2, n3) assert.notStrictEqual(n0, n2) })) + + it.live("choice", () => + Effect.gen(function*() { + expect(yield* Random.choice([]).pipe(Effect.flip)).toEqual(new Cause.NoSuchElementException()) + expect(yield* Random.choice([1])).toEqual(1) + + const randomItems = yield* Random.choice([1, 2, 3]).pipe(Array.replicate(100), Effect.all) + expect(Array.intersection(randomItems, [1, 2, 3]).length).toEqual(randomItems.length) + + expect(yield* Random.choice(Chunk.fromIterable([1, 2, 3]))).oneOf([1, 2, 3]) + })) }) diff --git a/packages/effect/test/Stream/async.test.ts b/packages/effect/test/Stream/async.test.ts index 3d6e5efa2f..7b7e979f6d 100644 --- a/packages/effect/test/Stream/async.test.ts +++ b/packages/effect/test/Stream/async.test.ts @@ -406,4 +406,60 @@ describe("Stream", () => { yield* $(Fiber.interrupt(fiber), Effect.exit) assert.isFalse(result) })) + + it.effect("asyncPush", () => + Effect.gen(function*() { + const array = [1, 2, 3, 4, 5] + const latch = yield* Deferred.make() + const fiber = yield* Stream.asyncPush((emit) => { + array.forEach((n) => { + emit.single(n) + }) + return pipe( + Deferred.succeed(latch, void 0), + Effect.asVoid + ) + }).pipe( + Stream.take(array.length), + Stream.run(Sink.collectAll()), + Effect.fork + ) + yield* Deferred.await(latch) + const result = yield* Fiber.join(fiber) + assert.deepStrictEqual(Array.from(result), array) + })) + + it.effect("asyncPush - signals the end of the stream", () => + Effect.gen(function*() { + const result = yield* Stream.asyncPush((emit) => { + emit.end() + return Effect.void + }).pipe(Stream.runCollect) + assert.isTrue(Chunk.isEmpty(result)) + })) + + it.effect("asyncPush - handles errors", () => + Effect.gen(function*() { + const error = new Cause.RuntimeException("boom") + const result = yield* Stream.asyncPush((emit) => { + emit.fail(error) + return Effect.void + }).pipe( + Stream.runCollect, + Effect.exit + ) + assert.deepStrictEqual(result, Exit.fail(error)) + })) + + it.effect("asyncPush - handles defects", () => + Effect.gen(function*() { + const error = new Cause.RuntimeException("boom") + const result = yield* Stream.asyncPush(() => { + throw error + }).pipe( + Stream.runCollect, + Effect.exit + ) + assert.deepStrictEqual(result, Exit.die(error)) + })) }) diff --git a/packages/effect/test/Stream/lifecycle.test.ts b/packages/effect/test/Stream/lifecycle.test.ts new file mode 100644 index 0000000000..9685d5abca --- /dev/null +++ b/packages/effect/test/Stream/lifecycle.test.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect" +import * as Stream from "effect/Stream" +import * as it from "effect/test/utils/extend" +import { assert, describe } from "vitest" + +describe("Stream", () => { + it.effect("onStart", () => + Effect.gen(function*($) { + let counter = 0 + const result = yield* $( + Stream.make(1, 1), + Stream.onStart(Effect.sync(() => counter++)), + Stream.runCollect + ) + assert.strictEqual(counter, 1) + assert.deepStrictEqual(Array.from(result), [1, 1]) + })) + + it.effect("onEnd", () => + Effect.gen(function*($) { + let counter = 0 + const result = yield* $( + Stream.make(1, 2, 3), + Stream.onEnd(Effect.sync(() => counter++)), + Stream.runCollect + ) + assert.strictEqual(counter, 1) + assert.deepStrictEqual(Array.from(result), [1, 2, 3]) + })) +}) diff --git a/packages/effect/test/Stream/tapping.test.ts b/packages/effect/test/Stream/tapping.test.ts index 2d47b72940..37bea2cbe9 100644 --- a/packages/effect/test/Stream/tapping.test.ts +++ b/packages/effect/test/Stream/tapping.test.ts @@ -97,7 +97,11 @@ describe("Stream", () => { const result = yield* $( Stream.make(1, 2, 3), Stream.tapBoth({ - onSuccess: (n) => pipe(Effect.fail("error"), Effect.when(() => n === 3)), + onSuccess: (n) => + pipe( + Effect.fail("error"), + Effect.when(() => n === 3) + ), onFailure: () => Effect.void }), Stream.either, diff --git a/packages/platform-browser/src/BrowserStream.ts b/packages/platform-browser/src/BrowserStream.ts index 345372b7ba..50c22d45ec 100644 --- a/packages/platform-browser/src/BrowserStream.ts +++ b/packages/platform-browser/src/BrowserStream.ts @@ -11,8 +11,13 @@ import * as internal from "./internal/stream.js" */ export const fromEventListenerWindow: ( type: K, - options?: boolean | Omit -) => Stream.Stream = internal.fromEventListenerWindow + options?: boolean | { + readonly capture?: boolean + readonly passive?: boolean + readonly once?: boolean + readonly bufferSize?: number | "unbounded" | undefined + } | undefined +) => Stream.Stream = internal.fromEventListenerWindow /** * Creates a `Stream` from document.addEventListener. @@ -20,5 +25,10 @@ export const fromEventListenerWindow: ( */ export const fromEventListenerDocument: ( type: K, - options?: boolean | Omit -) => Stream.Stream = internal.fromEventListenerDocument + options?: boolean | { + readonly capture?: boolean + readonly passive?: boolean + readonly once?: boolean + readonly bufferSize?: number | "unbounded" | undefined + } | undefined +) => Stream.Stream = internal.fromEventListenerDocument diff --git a/packages/platform-browser/src/internal/stream.ts b/packages/platform-browser/src/internal/stream.ts index a37cdca36c..b38e590135 100644 --- a/packages/platform-browser/src/internal/stream.ts +++ b/packages/platform-browser/src/internal/stream.ts @@ -7,11 +7,21 @@ import * as Stream from "effect/Stream" /** @internal */ export const fromEventListenerWindow = ( type: K, - options?: boolean | Omit + options?: boolean | { + readonly capture?: boolean + readonly passive?: boolean + readonly once?: boolean + readonly bufferSize?: number | "unbounded" | undefined + } | undefined ) => Stream.fromEventListener(window, type, options) /** @internal */ export const fromEventListenerDocument = ( type: K, - options?: boolean | Omit + options?: boolean | { + readonly capture?: boolean + readonly passive?: boolean + readonly once?: boolean + readonly bufferSize?: number | "unbounded" | undefined + } | undefined ) => Stream.fromEventListener(document, type, options) diff --git a/packages/platform/README.md b/packages/platform/README.md index 41c7e27c0b..1234cad7bf 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -295,7 +295,7 @@ Here's a list of operations that can be performed using the `FileSystem` tag: | **makeTempFile** | `options?: MakeTempFileOptions` | `Effect` | Create a temporary file. The directory creation is functionally equivalent to `makeTempDirectory`. The file name will be a randomly generated string. | | **makeTempFileScoped** | `options?: MakeTempFileOptions` | `Effect` | Create a temporary file inside a scope. Functionally equivalent to `makeTempFile`, but the file will be automatically deleted when the scope is closed. | | **open** | `path: string`, `options?: OpenFileOptions` | `Effect` | Open a file at `path` with the specified `options`. The file handle will be automatically closed when the scope is closed. | -| **readDirectory** | `path: string`, `options?: ReadDirectoryOptions` | `Effect, PlatformError>` | List the contents of a directory. You can recursively list the contents of nested directories by setting the `recursive` option. | +| **readDirectory** | `path: string`, `options?: ReadDirectoryOptions` | `Effect, PlatformError>` | List the contents of a directory. You can recursively list the contents of nested directories by setting the `recursive` option. | | **readFile** | `path: string` | `Effect` | Read the contents of a file. | | **readFileString** | `path: string`, `encoding?: string` | `Effect` | Read the contents of a file as a string. | | **readLink** | `path: string` | `Effect` | Read the destination of a symbolic link. | diff --git a/packages/platform/src/Command.ts b/packages/platform/src/Command.ts index f224fd4e97..d9eb48c751 100644 --- a/packages/platform/src/Command.ts +++ b/packages/platform/src/Command.ts @@ -172,7 +172,7 @@ export const flatten: (self: Command) => NonEmptyReadonlyArray export const lines: ( command: Command, encoding?: string -) => Effect, PlatformError, CommandExecutor> = internal.lines +) => Effect, PlatformError, CommandExecutor> = internal.lines /** * Create a command with the specified process name and an optional list of diff --git a/packages/platform/src/CommandExecutor.ts b/packages/platform/src/CommandExecutor.ts index bea7f74053..85ff4ae63b 100644 --- a/packages/platform/src/CommandExecutor.ts +++ b/packages/platform/src/CommandExecutor.ts @@ -52,7 +52,7 @@ export interface CommandExecutor { * * If an encoding is not specified, the encoding will default to `utf-8`. */ - readonly lines: (command: Command, encoding?: string) => Effect, PlatformError> + readonly lines: (command: Command, encoding?: string) => Effect, PlatformError> /** * Runs the command returning the output as a `Stream`. */ diff --git a/packages/platform/src/FileSystem.ts b/packages/platform/src/FileSystem.ts index fb1babaf72..db9ed8f589 100644 --- a/packages/platform/src/FileSystem.ts +++ b/packages/platform/src/FileSystem.ts @@ -137,7 +137,7 @@ export interface FileSystem { readonly readDirectory: ( path: string, options?: ReadDirectoryOptions - ) => Effect.Effect, PlatformError> + ) => Effect.Effect, PlatformError> /** * Read the contents of a file. */ diff --git a/packages/platform/src/Transferable.ts b/packages/platform/src/Transferable.ts index b7cb9e3cd4..13f4d31466 100644 --- a/packages/platform/src/Transferable.ts +++ b/packages/platform/src/Transferable.ts @@ -15,8 +15,8 @@ import * as Option from "effect/Option" export interface CollectorService { readonly addAll: (_: Iterable) => Effect.Effect readonly unsafeAddAll: (_: Iterable) => void - readonly read: Effect.Effect> - readonly unsafeRead: () => ReadonlyArray + readonly read: Effect.Effect> + readonly unsafeRead: () => Array readonly unsafeClear: () => void readonly clear: Effect.Effect } @@ -41,7 +41,7 @@ export const unsafeMakeCollector = (): CollectorService => { tranferables.push(transfer) } } - const unsafeRead = (): ReadonlyArray => tranferables + const unsafeRead = (): Array => tranferables const unsafeClear = (): void => { tranferables.length = 0 } diff --git a/packages/platform/src/internal/command.ts b/packages/platform/src/internal/command.ts index b8ef6ec47b..3dad65929e 100644 --- a/packages/platform/src/internal/command.ts +++ b/packages/platform/src/internal/command.ts @@ -98,7 +98,7 @@ export const runInShell = dual< export const lines = ( command: Command.Command, encoding = "utf-8" -): Effect.Effect, PlatformError, CommandExecutor.CommandExecutor> => +): Effect.Effect, PlatformError, CommandExecutor.CommandExecutor> => Effect.flatMap(commandExecutor.CommandExecutor, (executor) => executor.lines(command, encoding)) const Proto = { diff --git a/packages/platform/src/internal/commandExecutor.ts b/packages/platform/src/internal/commandExecutor.ts index f56979a4b4..1ae82c8497 100644 --- a/packages/platform/src/internal/commandExecutor.ts +++ b/packages/platform/src/internal/commandExecutor.ts @@ -52,7 +52,7 @@ export const makeExecutor = (start: _CommandExecutor.CommandExecutor["start"]): return pipe( streamLines(command, encoding), Stream.runCollect, - Effect.map(Chunk.toReadonlyArray) + Effect.map(Chunk.toArray) ) }, streamLines diff --git a/packages/rpc/src/Router.ts b/packages/rpc/src/Router.ts index 2b2b49c97e..786007341e 100644 --- a/packages/rpc/src/Router.ts +++ b/packages/rpc/src/Router.ts @@ -317,7 +317,7 @@ export const toHandlerEffect = >(router: R, options?: const getEncode = withRequestTag((req) => Schema.encode(Serializable.exitSchema(req))) const getEncodeChunk = withRequestTag((req) => Schema.encode(Schema.Chunk(Serializable.exitSchema(req)))) - return (u: unknown): Effect.Effect, ParseError, Router.Context> => + return (u: unknown): Effect.Effect, ParseError, Router.Context> => Effect.flatMap( decode(u), Effect.forEach((req): Effect.Effect => { diff --git a/packages/schema/src/Schema.ts b/packages/schema/src/Schema.ts index c95033f020..9c0c3fb556 100644 --- a/packages/schema/src/Schema.ts +++ b/packages/schema/src/Schema.ts @@ -12,6 +12,7 @@ import * as chunk_ from "effect/Chunk" import * as config_ from "effect/Config" import * as configError_ from "effect/ConfigError" import * as data_ from "effect/Data" +import * as dateTime from "effect/DateTime" import * as duration_ from "effect/Duration" import * as Effect from "effect/Effect" import * as either_ from "effect/Either" @@ -5919,6 +5920,209 @@ export class DateFromNumber extends transform( { strict: true, decode: (n) => new Date(n), encode: (d) => d.getTime() } ).annotations({ identifier: "DateFromNumber" }) {} +/** + * Describes a schema that represents a `DateTime.Utc` instance. + * + * @category DateTime.Utc constructors + * @since 0.68.27 + */ +export class DateTimeUtcFromSelf extends declare( + (u) => dateTime.isDateTime(u) && dateTime.isUtc(u), + { + identifier: "DateTimeUtcFromSelf", + description: "a DateTime.Utc instance", + pretty: (): pretty_.Pretty => (dateTime) => dateTime.toString(), + arbitrary: (): LazyArbitrary => (fc) => fc.date().map((date) => dateTime.unsafeFromDate(date)), + equivalence: () => dateTime.Equivalence + } +) {} + +const decodeDateTime = (input: A, _: ParseOptions, ast: AST.AST) => + ParseResult.try({ + try: () => dateTime.unsafeMake(input), + catch: () => new ParseResult.Type(ast, input) + }) + +/** + * Defines a schema that attempts to convert a `number` to a `DateTime.Utc` instance using the `DateTime.unsafeMake` constructor. + * + * @category DateTime.Utc transformations + * @since 0.68.27 + */ +export class DateTimeUtcFromNumber extends transformOrFail( + Number$, + DateTimeUtcFromSelf, + { + strict: true, + decode: decodeDateTime, + encode: (dt) => ParseResult.succeed(dateTime.toEpochMillis(dt)) + } +).annotations({ identifier: "DateTimeUtcFromNumber" }) {} + +/** + * Defines a schema that attempts to convert a `string` to a `DateTime.Utc` instance using the `DateTime.unsafeMake` constructor. + * + * @category DateTime.Utc transformations + * @since 0.68.27 + */ +export class DateTimeUtc extends transformOrFail( + String$, + DateTimeUtcFromSelf, + { + strict: true, + decode: decodeDateTime, + encode: (dt) => ParseResult.succeed(dateTime.formatIso(dt)) + } +).annotations({ identifier: "DateTimeUtc" }) {} + +const timeZoneOffsetArbitrary = (): LazyArbitrary => (fc) => + fc.integer({ min: -12 * 60 * 60 * 1000, max: 12 * 60 * 60 * 1000 }).map((offset) => dateTime.zoneMakeOffset(offset)) + +/** + * Describes a schema that represents a `TimeZone.Offset` instance. + * + * @category TimeZone constructors + * @since 0.68.27 + */ +export class TimeZoneOffsetFromSelf extends declare( + dateTime.isTimeZoneOffset, + { + identifier: "TimeZoneOffsetFromSelf", + description: "a TimeZone.Offset instance", + pretty: (): pretty_.Pretty => (zone) => zone.toString(), + arbitrary: timeZoneOffsetArbitrary + } +) {} + +/** + * Defines a schema that converts a `number` to a `TimeZone.Offset` instance using the `DateTime.zoneMakeOffset` constructor. + * + * @category TimeZone transformations + * @since 0.68.27 + */ +export class TimeZoneOffset extends transform( + Number$, + TimeZoneOffsetFromSelf, + { strict: true, decode: dateTime.zoneMakeOffset, encode: (tz) => tz.offset } +).annotations({ identifier: "TimeZoneOffset" }) {} + +const timeZoneNamedArbitrary = (): LazyArbitrary => (fc) => + fc.constantFrom(...Intl.supportedValuesOf("timeZone")).map(dateTime.zoneUnsafeMakeNamed) + +/** + * Describes a schema that represents a `TimeZone.Named` instance. + * + * @category TimeZone constructors + * @since 0.68.27 + */ +export class TimeZoneNamedFromSelf extends declare( + dateTime.isTimeZoneNamed, + { + identifier: "TimeZoneNamedFromSelf", + description: "a TimeZone.Named instance", + pretty: (): pretty_.Pretty => (zone) => zone.toString(), + arbitrary: timeZoneNamedArbitrary + } +) {} + +/** + * Defines a schema that attempts to convert a `string` to a `TimeZone.Named` instance using the `DateTime.zoneUnsafeMakeNamed` constructor. + * + * @category TimeZone transformations + * @since 0.68.27 + */ +export class TimeZoneNamed extends transformOrFail( + String$, + TimeZoneNamedFromSelf, + { + strict: true, + decode: (s, _, ast) => + ParseResult.try({ + try: () => dateTime.zoneUnsafeMakeNamed(s), + catch: () => new ParseResult.Type(ast, s) + }), + encode: (tz) => ParseResult.succeed(tz.id) + } +).annotations({ identifier: "TimeZoneNamed" }) {} + +/** + * @category api interface + * @since 0.68.27 + */ +export interface TimeZoneFromSelf extends Union<[typeof TimeZoneOffsetFromSelf, typeof TimeZoneNamedFromSelf]> { + annotations(annotations: Annotations.Schema): TimeZoneFromSelf +} + +/** + * @category TimeZone constructors + * @since 0.68.27 + */ +export const TimeZoneFromSelf: TimeZoneFromSelf = Union(TimeZoneOffsetFromSelf, TimeZoneNamedFromSelf) + +/** + * Defines a schema that attempts to convert a `string` to a `TimeZone` using the `DateTime.zoneFromString` constructor. + * + * @category TimeZone transformations + * @since 0.68.27 + */ +export class TimeZone extends transformOrFail( + String$, + TimeZoneFromSelf, + { + strict: true, + decode: (s, _, ast) => + option_.match(dateTime.zoneFromString(s), { + onNone: () => ParseResult.fail(new ParseResult.Type(ast, s)), + onSome: ParseResult.succeed + }), + encode: (tz) => ParseResult.succeed(dateTime.zoneToString(tz)) + } +).annotations({ identifier: "TimeZone" }) {} + +const timeZoneArbitrary: LazyArbitrary = (fc) => + fc.oneof( + timeZoneOffsetArbitrary()(fc), + timeZoneNamedArbitrary()(fc) + ) + +/** + * Describes a schema that represents a `DateTime.Zoned` instance. + * + * @category DateTime.Zoned constructors + * @since 0.68.27 + */ +export class DateTimeZonedFromSelf extends declare( + (u) => dateTime.isDateTime(u) && dateTime.isZoned(u), + { + identifier: "DateTimeZonedFromSelf", + description: "a DateTime.Zoned instance", + pretty: (): pretty_.Pretty => (dateTime) => dateTime.toString(), + arbitrary: (): LazyArbitrary => (fc) => + fc.date().chain((date) => timeZoneArbitrary(fc).map((timeZone) => dateTime.unsafeMakeZoned(date, { timeZone }))), + equivalence: () => dateTime.Equivalence + } +) {} + +/** + * Defines a schema that attempts to convert a `string` to a `DateTime.Zoned` instance. + * + * @category DateTime.Zoned transformations + * @since 0.68.27 + */ +export class DateTimeZoned extends transformOrFail( + String$, + DateTimeZonedFromSelf, + { + strict: true, + decode: (s, _, ast) => + option_.match(dateTime.makeZonedFromString(s), { + onNone: () => ParseResult.fail(new ParseResult.Type(ast, s)), + onSome: ParseResult.succeed + }), + encode: (dt) => ParseResult.succeed(dateTime.formatIsoZoned(dt)) + } +).annotations({ identifier: "DateTimeZoned" }) {} + /** * @category Option utils * @since 0.67.0 diff --git a/packages/schema/test/Schema/DateTime/DateTime.test.ts b/packages/schema/test/Schema/DateTime/DateTime.test.ts new file mode 100644 index 0000000000..75138c2d2b --- /dev/null +++ b/packages/schema/test/Schema/DateTime/DateTime.test.ts @@ -0,0 +1,62 @@ +import * as S from "@effect/schema/Schema" +import * as Util from "@effect/schema/test/TestUtils" +import { DateTime } from "effect" +import { describe, it } from "vitest" + +describe("DateTime.Utc", () => { + const schema = S.DateTimeUtc + + it("property tests", () => { + Util.roundtrip(schema) + }) + + it("decoding", async () => { + await Util.expectDecodeUnknownSuccess( + schema, + "1970-01-01T00:00:00.000Z", + DateTime.unsafeMake(0) + ) + await Util.expectDecodeUnknownFailure( + schema, + "a", + `DateTimeUtc +└─ Transformation process failure + └─ Expected DateTimeUtc, actual "a"` + ) + }) + + it("encoding", async () => { + await Util.expectEncodeSuccess(schema, DateTime.unsafeMake(0), "1970-01-01T00:00:00.000Z") + }) +}) + +describe("DateTime.Zoned", () => { + const schema = S.DateTimeZoned + const dt = DateTime.unsafeMakeZoned(0, { timeZone: "Europe/London" }) + + it("property tests", () => { + Util.roundtrip(schema) + }) + + it("decoding", async () => { + await Util.expectDecodeUnknownSuccess(schema, "1970-01-01T01:00:00.000+01:00[Europe/London]", dt) + await Util.expectDecodeUnknownFailure( + schema, + "1970-01-01T00:00:00.000Z", + `DateTimeZoned +└─ Transformation process failure + └─ Expected DateTimeZoned, actual "1970-01-01T00:00:00.000Z"` + ) + await Util.expectDecodeUnknownFailure( + schema, + "a", + `DateTimeZoned +└─ Transformation process failure + └─ Expected DateTimeZoned, actual "a"` + ) + }) + + it("encoding", async () => { + await Util.expectEncodeSuccess(schema, dt, "1970-01-01T01:00:00.000+01:00[Europe/London]") + }) +}) diff --git a/packages/sql-kysely/CHANGELOG.md b/packages/sql-kysely/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sql-kysely/LICENSE b/packages/sql-kysely/LICENSE new file mode 100644 index 0000000000..7f6fe480f7 --- /dev/null +++ b/packages/sql-kysely/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-present The Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sql-kysely/README.md b/packages/sql-kysely/README.md new file mode 100644 index 0000000000..5883301df8 --- /dev/null +++ b/packages/sql-kysely/README.md @@ -0,0 +1,15 @@ +# Effect SQL - Kysely + +A Kysely integration for @effect/sql. + +## Disclaimer + +This integration is not fully `Future Proof` as it's dependant on some `Kysely` internals. +Meaning that if `Kysely` changes some of its internals (new Builders, Builders renaming, Builders removing, Builders splitting, etc), this integration might break. +So use it at your own risk or pin your `Kysely` version to be sure to not have any breaking changes. + +## Compatibility matrix + +| Kysely version | Effect SQL - Kysely support | +| --------------- | --------------------------- | +| 0.26.1 - 0.27.3 | ✅ | diff --git a/packages/sql-kysely/docgen.json b/packages/sql-kysely/docgen.json new file mode 100644 index 0000000000..f980a32f52 --- /dev/null +++ b/packages/sql-kysely/docgen.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../node_modules/@effect/docgen/schema.json", + "exclude": [ + "src/internal/**/*.ts" + ] +} diff --git a/packages/sql-kysely/examples/sqlite.ts b/packages/sql-kysely/examples/sqlite.ts new file mode 100644 index 0000000000..c5b2dd57f3 --- /dev/null +++ b/packages/sql-kysely/examples/sqlite.ts @@ -0,0 +1,50 @@ +import * as SqliteKysely from "@effect/sql-kysely/Sqlite" +import * as Sqlite from "@effect/sql-sqlite-node" +import { Config, Console, Context, Effect, Exit, Layer } from "effect" +import type { Generated } from "kysely" + +export interface User { + id: Generated + name: string +} + +interface Database { + users: User +} + +class SqliteDB extends Context.Tag("SqliteDB")>() {} + +const SqliteLive = Sqlite.SqliteClient.layer({ + filename: Config.succeed(":memory:") +}) + +const KyselyLive = Layer.effect(SqliteDB, SqliteKysely.make()).pipe(Layer.provide(SqliteLive)) + +Effect.gen(function*(_) { + const db = yield* SqliteDB + + yield* db.schema + .createTable("users") + .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement()) + .addColumn("name", "text", (c) => c.notNull()) + + const result = yield* db.withTransaction( + Effect.gen(function*() { + const inserted = yield* db.insertInto("users").values({ name: "Alice" }).returningAll() + yield* Console.log(inserted) + const selected = yield* db.selectFrom("users").selectAll() + yield* Console.log(selected) + const updated = yield* db.updateTable("users").set({ name: "Bob" }).returningAll() + yield* Console.log(updated) + return yield* Effect.fail(new Error("rollback")) + }) + ).pipe(Effect.exit) + if (Exit.isSuccess(result)) { + return yield* Effect.fail("should not reach here") + } + const selected = yield* db.selectFrom("users").selectAll() + yield* Console.log(selected) +}).pipe( + Effect.provide(KyselyLive), + Effect.runPromise +) diff --git a/packages/sql-kysely/package.json b/packages/sql-kysely/package.json new file mode 100644 index 0000000000..a9da335b64 --- /dev/null +++ b/packages/sql-kysely/package.json @@ -0,0 +1,64 @@ +{ + "name": "@effect/sql-kysely", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "Kysely integration for @effect/sql", + "homepage": "https://effect.website", + "repository": { + "type": "git", + "url": "https://github.com/Effect-TS/effect.git", + "directory": "packages/sql-kysely" + }, + "bugs": { + "url": "https://github.com/Effect-TS/effect/issues" + }, + "tags": [ + "typescript", + "sql", + "database" + ], + "keywords": [ + "typescript", + "sql", + "database" + ], + "publishConfig": { + "access": "public", + "directory": "dist", + "provenance": true + }, + "sideEffects": [ + "./src/Kysely.ts", + "./src/Mysql.ts", + "./src/Pg.ts", + "./src/Sqlite.ts" + ], + "scripts": { + "build": "pnpm build-esm && pnpm build-cjs && pnpm build-annotate && build-utils pack-v2", + "build-esm": "tsc -b tsconfig.build.json", + "build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "build-annotate": "babel build --plugins annotate-pure-calls --out-dir build --source-maps", + "check": "tsc -b tsconfig.json", + "test": "vitest", + "coverage": "vitest --coverage" + }, + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.24.1" + }, + "devDependencies": { + "@effect/sql": "workspace:^", + "@testcontainers/mssqlserver": "^10.9.0", + "@testcontainers/mysql": "^10.9.0", + "@testcontainers/postgresql": "^10.9.0", + "@types/better-sqlite3": "^7.6.10", + "better-sqlite3": "^11.0.0", + "effect": "workspace:^", + "kysely": "^0.27.3" + }, + "peerDependencies": { + "@effect/sql": "workspace:^", + "effect": "workspace:^", + "kysely": "^0.27.3" + } +} diff --git a/packages/sql-kysely/src/Kysely.ts b/packages/sql-kysely/src/Kysely.ts new file mode 100644 index 0000000000..9c99713f0a --- /dev/null +++ b/packages/sql-kysely/src/Kysely.ts @@ -0,0 +1,16 @@ +/** + * @since 1.0.0 + */ +import * as internal from "./internal/kysely.js" + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = internal.makeWithExecute + +/** + * @since 1.0.0 + * @category types + */ +export type * from "./patch.types.js" diff --git a/packages/sql-kysely/src/Mssql.ts b/packages/sql-kysely/src/Mssql.ts new file mode 100644 index 0000000000..4bb748ac74 --- /dev/null +++ b/packages/sql-kysely/src/Mssql.ts @@ -0,0 +1,27 @@ +/** + * @since 1.0.0 + */ +import type { KyselyConfig } from "kysely" +import { DummyDriver, MssqlAdapter, MssqlIntrospector, MssqlQueryCompiler } from "kysely" +import * as internal from "./internal/kysely.js" + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = (config?: Omit) => + internal.makeWithSql({ + ...config, + dialect: { + createAdapter: () => new MssqlAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new MssqlIntrospector(db), + createQueryCompiler: () => new MssqlQueryCompiler() + } + }) + +/** + * @since 1.0.0 + * @category types + */ +export type * from "./patch.types.js" diff --git a/packages/sql-kysely/src/Mysql.ts b/packages/sql-kysely/src/Mysql.ts new file mode 100644 index 0000000000..3ee8cd8e01 --- /dev/null +++ b/packages/sql-kysely/src/Mysql.ts @@ -0,0 +1,27 @@ +/** + * @since 1.0.0 + */ +import type { KyselyConfig } from "kysely" +import { DummyDriver, MysqlAdapter, MysqlIntrospector, MysqlQueryCompiler } from "kysely" +import * as internal from "./internal/kysely.js" + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = (config?: Omit) => + internal.makeWithSql({ + ...config, + dialect: { + createAdapter: () => new MysqlAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new MysqlIntrospector(db), + createQueryCompiler: () => new MysqlQueryCompiler() + } + }) + +/** + * @since 1.0.0 + * @category types + */ +export type * from "./patch.types.js" diff --git a/packages/sql-kysely/src/Pg.ts b/packages/sql-kysely/src/Pg.ts new file mode 100644 index 0000000000..30182d28dd --- /dev/null +++ b/packages/sql-kysely/src/Pg.ts @@ -0,0 +1,27 @@ +/** + * @since 1.0.0 + */ +import type { KyselyConfig } from "kysely" +import { DummyDriver, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely" +import * as internal from "./internal/kysely.js" + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = (config?: Omit) => + internal.makeWithSql({ + ...config, + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler() + } + }) + +/** + * @since 1.0.0 + * @category types + */ +export type * from "./patch.types.js" diff --git a/packages/sql-kysely/src/Sqlite.ts b/packages/sql-kysely/src/Sqlite.ts new file mode 100644 index 0000000000..8eac355d85 --- /dev/null +++ b/packages/sql-kysely/src/Sqlite.ts @@ -0,0 +1,27 @@ +/** + * @since 1.0.0 + */ +import type { KyselyConfig } from "kysely" +import { DummyDriver, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely" +import * as internal from "./internal/kysely.js" + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = (config?: Omit) => + internal.makeWithSql({ + ...config, + dialect: { + createAdapter: () => new SqliteAdapter(), + createDriver: () => new DummyDriver(), + createIntrospector: (db) => new SqliteIntrospector(db), + createQueryCompiler: () => new SqliteQueryCompiler() + } + }) + +/** + * @since 1.0.0 + * @category types + */ +export type * from "./patch.types.js" diff --git a/packages/sql-kysely/src/internal/kysely.ts b/packages/sql-kysely/src/internal/kysely.ts new file mode 100644 index 0000000000..b275038ae5 --- /dev/null +++ b/packages/sql-kysely/src/internal/kysely.ts @@ -0,0 +1,80 @@ +/** + * @since 1.0.0 + */ +import * as Client from "@effect/sql/SqlClient" +import * as Effect from "effect/Effect" +import { + AlterTableColumnAlteringBuilder, + CreateIndexBuilder, + CreateSchemaBuilder, + CreateTableBuilder, + CreateTypeBuilder, + CreateViewBuilder, + DeleteQueryBuilder, + DropIndexBuilder, + DropSchemaBuilder, + DropTableBuilder, + DropTypeBuilder, + DropViewBuilder, + InsertQueryBuilder, + Kysely, + UpdateQueryBuilder, + WheneableMergeQueryBuilder +} from "kysely" +import type { KyselyConfig } from "kysely" +import type { EffectKysely } from "../patch.types.js" +import { effectifyWithExecute, effectifyWithSql, patch } from "./patch.js" + +/** + * @internal + * patch all compilable/executable builders with commit prototypes + * + * @warning side effect + */ +patch(AlterTableColumnAlteringBuilder.prototype) +patch(CreateIndexBuilder.prototype) +patch(CreateSchemaBuilder.prototype) +patch(CreateTableBuilder.prototype) +patch(CreateTypeBuilder.prototype) +patch(CreateViewBuilder.prototype) +patch(DropIndexBuilder.prototype) +patch(DropSchemaBuilder.prototype) +patch(DropTableBuilder.prototype) +patch(DropTypeBuilder.prototype) +patch(DropViewBuilder.prototype) +patch(InsertQueryBuilder.prototype) +patch(UpdateQueryBuilder.prototype) +patch(DeleteQueryBuilder.prototype) +patch(WheneableMergeQueryBuilder.prototype) + +/** + * @internal + * create a Kysely instance from a dialect + * and using an effect/sql client backend + */ +export const makeWithSql = (config: KyselyConfig) => + Effect.gen(function*() { + const client = yield* Client.SqlClient + + const db = new Kysely(config) as unknown as EffectKysely + db.withTransaction = client.withTransaction + + // SelectQueryBuilder is not exported from "kysely" so we patch the prototype from it's instance + const selectPrototype = Object.getPrototypeOf(db.selectFrom("" as any)) + patch(selectPrototype) + + return effectifyWithSql(db, client, ["withTransaction", "compile"]) + }) + +/** + * @internal + * create a Kysely instance from a dialect + * and using the native kysely driver + */ +export const makeWithExecute = (config: KyselyConfig) => { + const db = new Kysely(config) + // SelectQueryBuilder is not exported from "kysely" so we patch the prototype from it's instance + const selectPrototype = Object.getPrototypeOf(db.selectFrom("" as any)) + patch(selectPrototype) + return effectifyWithExecute(db) +} diff --git a/packages/sql-kysely/src/internal/patch.ts b/packages/sql-kysely/src/internal/patch.ts new file mode 100644 index 0000000000..ea4245cbf7 --- /dev/null +++ b/packages/sql-kysely/src/internal/patch.ts @@ -0,0 +1,90 @@ +import type * as Client from "@effect/sql/SqlClient" +import { SqlError } from "@effect/sql/SqlError" +import * as Otel from "@opentelemetry/semantic-conventions" +import * as Effect from "effect/Effect" +import * as Effectable from "effect/Effectable" +import type { Compilable } from "kysely" + +interface Executable extends Compilable { + execute: () => Promise> +} + +const COMMIT_ERROR = "Kysely instance not properly initialised: use 'make' to create an Effect compatible instance" + +const PatchProto = { + ...Effectable.CommitPrototype, + commit() { + return Effect.die(new Error(COMMIT_ERROR)) + } +} + +/** @internal */ +export const patch = (prototype: any) => { + if (!(Effect.EffectTypeId in prototype)) { + Object.assign(prototype, PatchProto) + } +} + +/** + * @internal + * replace at runtime the commit method on instances that have been patched by the provided one + * this allows multiple client db instances to have different drivers (@effect/sql or kysely) + */ +function effectifyWith( + obj: any, + commit: () => Effect.Effect, SqlError>, + whitelist: Array +) { + if (typeof obj !== "object") { + return obj + } + return new Proxy(obj, { + get(target, prop): any { + const prototype = Object.getPrototypeOf(target) + if (Effect.EffectTypeId in prototype && prop === "commit") { + return commit.bind(target) + } + if (typeof (target[prop]) === "function") { + if (typeof prop === "string" && whitelist.includes(prop)) { + return target[prop].bind(target) + } + return (...args: Array) => effectifyWith(target[prop].call(target, ...args), commit, whitelist) + } + return effectifyWith(target[prop], commit, whitelist) + } + }) +} + +/** @internal */ +const makeSqlCommit = (client: Client.SqlClient) => { + return function(this: Compilable) { + const { parameters, sql } = this.compile() + return client.unsafe(sql, parameters as any) + } +} + +/** @internal */ +function executeCommit(this: Executable) { + return Effect.tryPromise({ + try: () => this.execute(), + catch: (cause) => new SqlError({ cause }) + }).pipe(Effect.withSpan("kysely.execute", { + kind: "client", + captureStackTrace: false, + attributes: { + [Otel.SEMATTRS_DB_STATEMENT]: this.compile().sql + } + })) +} + +/** + * @internal + */ +export const effectifyWithSql = (obj: T, client: Client.SqlClient, whitelist: Array = []): T => + effectifyWith(obj, makeSqlCommit(client), whitelist) + +/** + * @internal + */ +export const effectifyWithExecute = (obj: T, whitelist: Array = []): T => + effectifyWith(obj, executeCommit, whitelist) diff --git a/packages/sql-kysely/src/patch.types.ts b/packages/sql-kysely/src/patch.types.ts new file mode 100644 index 0000000000..be5bd506af --- /dev/null +++ b/packages/sql-kysely/src/patch.types.ts @@ -0,0 +1,40 @@ +/** + * @since 1.0.0 + */ +import type { SqlError } from "@effect/sql/SqlError" +import type { Effect } from "effect" +import type { Kysely, Simplify } from "kysely" + +declare module "kysely" { + export interface AlterTableColumnAlteringBuilder extends Effect.Effect {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface CreateIndexBuilder extends Effect.Effect {} + export interface CreateSchemaBuilder extends Effect.Effect {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface CreateTableBuilder extends Effect.Effect {} + export interface CreateTypeBuilder extends Effect.Effect {} + export interface CreateViewBuilder extends Effect.Effect {} + export interface DropIndexBuilder extends Effect.Effect {} + export interface DropSchemaBuilder extends Effect.Effect {} + export interface DropTableBuilder extends Effect.Effect {} + export interface DropTypeBuilder extends Effect.Effect {} + export interface DropViewBuilder extends Effect.Effect {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface SelectQueryBuilder extends Effect.Effect>, SqlError> {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface InsertQueryBuilder extends Effect.Effect>, SqlError> {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface UpdateQueryBuilder extends Effect.Effect>, SqlError> {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface DeleteQueryBuilder extends Effect.Effect>, SqlError> {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface WheneableMergeQueryBuilder extends Effect.Effect>, SqlError> {} +} + +/** + * @since 1.0.0 + * @category types + */ +export interface EffectKysely extends Omit, "transaction"> { + withTransaction: (self: Effect.Effect) => Effect.Effect +} diff --git a/packages/sql-kysely/test/Kysely.test.ts b/packages/sql-kysely/test/Kysely.test.ts new file mode 100644 index 0000000000..774d43560b --- /dev/null +++ b/packages/sql-kysely/test/Kysely.test.ts @@ -0,0 +1,54 @@ +import * as SqlKysely from "@effect/sql-kysely/Kysely" +import { assert, describe, it } from "@effect/vitest" +import SqliteDB from "better-sqlite3" +import { Context, Effect, Layer } from "effect" +import { CamelCasePlugin, type Generated, type Kysely, SqliteDialect } from "kysely" + +export interface User { + id: Generated + userName: string +} + +interface Database { + users: User +} + +class KyselyDB extends Context.Tag("KyselyDB")>() {} + +const KyselyDBLive = Layer.sync(KyselyDB, () => + SqlKysely.make({ + dialect: new SqliteDialect({ + database: new SqliteDB(":memory:") + }), + plugins: [ + new CamelCasePlugin() + ] + })) + +describe("Kysely", () => { + it.effect("queries", () => + Effect.gen(function*(_) { + const db = yield* KyselyDB + + const createTableQuery = db.schema + .createTable("users") + .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement()) + .addColumn("userName", "text", (c) => c.notNull()) + + yield* createTableQuery + + const inserted = yield* db.insertInto("users").values({ userName: "Alice" }).returningAll() + const selected = yield* db.selectFrom("users").selectAll() + const updated = yield* db.updateTable("users").set({ userName: "Bob" }).returningAll() + const deleted = yield* db.deleteFrom("users").returningAll() + + assert.equal( + createTableQuery.compile().sql, + "create table \"users\" (\"id\" integer primary key autoincrement, \"user_name\" text not null)" + ) + assert.deepStrictEqual(inserted, [{ id: 1, userName: "Alice" }]) + assert.deepStrictEqual(selected, [{ id: 1, userName: "Alice" }]) + assert.deepStrictEqual(updated, [{ id: 1, userName: "Bob" }]) + assert.deepStrictEqual(deleted, [{ id: 1, userName: "Bob" }]) + }).pipe(Effect.provide(KyselyDBLive))) +}) diff --git a/packages/sql-kysely/test/Mssql.test.ts b/packages/sql-kysely/test/Mssql.test.ts new file mode 100644 index 0000000000..2c1fa44809 --- /dev/null +++ b/packages/sql-kysely/test/Mssql.test.ts @@ -0,0 +1,40 @@ +import * as MssqlKysely from "@effect/sql-kysely/Mssql" +import { assert, describe, it } from "@effect/vitest" +import { Context, Effect, Layer } from "effect" +import type { Generated } from "kysely" +import { MssqlContainer } from "./utils.js" + +export interface User { + id: Generated + name: string +} + +interface Database { + users: User +} + +class MssqlDB extends Context.Tag("PgDB")>() {} + +const MssqlLive = Layer.effect(MssqlDB, MssqlKysely.make()).pipe(Layer.provide(MssqlContainer.ClientLive)) + +describe("MssqlKysely", () => { + it.effect("queries", () => + Effect.gen(function*(_) { + const db = yield* MssqlDB + yield* db.schema + .createTable("users") + .addColumn("id", "integer", (c) => c.primaryKey().identity()) + .addColumn("name", "text", (c) => c.notNull()) + + yield* db.insertInto("users").values({ name: "Alice" }) + const inserted = yield* db.selectFrom("users").selectAll() + yield* db.updateTable("users").set({ name: "Bob" }) + const updated = yield* db.selectFrom("users").selectAll() + yield* db.deleteFrom("users") + const deleted = yield* db.selectFrom("users").selectAll() + + assert.deepStrictEqual(inserted, [{ id: 1, name: "Alice" }]) + assert.deepStrictEqual(updated, [{ id: 1, name: "Bob" }]) + assert.deepStrictEqual(deleted, []) + }).pipe(Effect.provide(MssqlLive)), { timeout: 60000 }) +}) diff --git a/packages/sql-kysely/test/Mysql.test.ts b/packages/sql-kysely/test/Mysql.test.ts new file mode 100644 index 0000000000..9204547ae6 --- /dev/null +++ b/packages/sql-kysely/test/Mysql.test.ts @@ -0,0 +1,41 @@ +import * as MysqlKysely from "@effect/sql-kysely/Mysql" +import { assert, describe, it } from "@effect/vitest" +import { Context, Effect, Layer } from "effect" +import type { Generated } from "kysely" +import { MysqlContainer } from "./utils.js" + +export interface User { + id: Generated + name: string +} + +interface Database { + users: User +} + +class MysqlDB extends Context.Tag("MysqlDB")>() {} + +const MysqlLive = Layer.effect(MysqlDB, MysqlKysely.make()).pipe(Layer.provide(MysqlContainer.ClientLive)) + +describe("MysqlKysely", () => { + it.effect("queries", () => + Effect.gen(function*(_) { + const db = yield* MysqlDB + + yield* db.schema + .createTable("users") + .addColumn("id", "serial", (c) => c.primaryKey()) + .addColumn("name", "text", (c) => c.notNull()) + + yield* db.insertInto("users").values({ name: "Alice" }) + const inserted = yield* db.selectFrom("users").selectAll() + yield* db.updateTable("users").set({ name: "Bob" }) + const updated = yield* db.selectFrom("users").selectAll() + yield* db.deleteFrom("users") + const deleted = yield* db.selectFrom("users").selectAll() + + assert.deepStrictEqual(inserted, [{ id: 1, name: "Alice" }]) + assert.deepStrictEqual(updated, [{ id: 1, name: "Bob" }]) + assert.deepStrictEqual(deleted, []) + }).pipe(Effect.provide(MysqlLive)), { timeout: 120000 }) +}) diff --git a/packages/sql-kysely/test/Pg.test.ts b/packages/sql-kysely/test/Pg.test.ts new file mode 100644 index 0000000000..0f813ff943 --- /dev/null +++ b/packages/sql-kysely/test/Pg.test.ts @@ -0,0 +1,39 @@ +import * as PgKysely from "@effect/sql-kysely/Pg" +import { assert, describe, it } from "@effect/vitest" +import { Context, Effect, Layer } from "effect" +import type { Generated } from "kysely" +import { PgContainer } from "./utils.js" + +export interface User { + id: Generated + name: string +} + +interface Database { + users: User +} + +class PgDB extends Context.Tag("PgDB")>() {} + +const PgLive = Layer.effect(PgDB, PgKysely.make()).pipe(Layer.provide(PgContainer.ClientLive)) + +describe("PgKysely", () => { + it.effect("queries", () => + Effect.gen(function*(_) { + const db = yield* PgDB + yield* db.schema + .createTable("users") + .addColumn("id", "serial", (c) => c.primaryKey()) + .addColumn("name", "text", (c) => c.notNull()) + + const inserted = yield* db.insertInto("users").values({ name: "Alice" }).returningAll() + const selected = yield* db.selectFrom("users").selectAll() + const updated = yield* db.updateTable("users").set({ name: "Bob" }).returningAll() + const deleted = yield* db.deleteFrom("users").returningAll() + + assert.deepStrictEqual(inserted, [{ id: 1, name: "Alice" }]) + assert.deepStrictEqual(selected, [{ id: 1, name: "Alice" }]) + assert.deepStrictEqual(updated, [{ id: 1, name: "Bob" }]) + assert.deepStrictEqual(deleted, [{ id: 1, name: "Bob" }]) + }).pipe(Effect.provide(PgLive)), { timeout: 60000 }) +}) diff --git a/packages/sql-kysely/test/Sqlite.test.ts b/packages/sql-kysely/test/Sqlite.test.ts new file mode 100644 index 0000000000..59eb3a311f --- /dev/null +++ b/packages/sql-kysely/test/Sqlite.test.ts @@ -0,0 +1,82 @@ +import { Schema } from "@effect/schema" +import { SqlResolver } from "@effect/sql" +import * as SqliteKysely from "@effect/sql-kysely/Sqlite" +import * as Sqlite from "@effect/sql-sqlite-node" +import { assert, describe, it } from "@effect/vitest" +import { Config, Context, Effect, Exit, Layer, Option } from "effect" +import type { Generated } from "kysely" + +export interface User { + id: Generated + name: string +} + +interface Database { + users: User +} + +class SqliteDB extends Context.Tag("SqliteDB")>() {} + +const SqliteLive = Sqlite.SqliteClient.layer({ + filename: Config.succeed(":memory:") +}) + +const KyselyLive = Layer.effect(SqliteDB, SqliteKysely.make()).pipe(Layer.provide(SqliteLive)) + +describe("SqliteKysely", () => { + it.effect("queries", () => + Effect.gen(function*(_) { + const db = yield* SqliteDB + + yield* db.schema + .createTable("users") + .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement()) + .addColumn("name", "text", (c) => c.notNull()) + + const result = yield* db.withTransaction( + Effect.gen(function*() { + const inserted = yield* db.insertInto("users").values({ name: "Alice" }).returningAll() + const selected = yield* db.selectFrom("users").selectAll() + const updated = yield* db.updateTable("users").set({ name: "Bob" }).returningAll() + assert.deepStrictEqual(inserted, [{ id: 1, name: "Alice" }]) + assert.deepStrictEqual(selected, [{ id: 1, name: "Alice" }]) + assert.deepStrictEqual(updated, [{ id: 1, name: "Bob" }]) + return yield* Effect.fail(new Error("rollback")) + }) + ).pipe(Effect.exit) + if (Exit.isSuccess(result)) { + assert.fail("should not reach here") + } + const selected = yield* db.selectFrom("users").selectAll() + assert.deepStrictEqual(selected, []) + }).pipe(Effect.provide(KyselyLive))) + + it.effect("select with resolver", () => + Effect.gen(function*(_) { + const db = yield* SqliteDB + + yield* db.schema + .createTable("users") + .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement()) + .addColumn("name", "text", (c) => c.notNull()) + + yield* db.insertInto("users").values({ name: "Alice" }) + yield* db.insertInto("users").values({ name: "Bob" }) + yield* db.insertInto("users").values({ name: "Charlie" }) + + const GetUserById = yield* SqlResolver.findById("GetUserById", { + Id: Schema.Number, + Result: Schema.Struct({ id: Schema.Number, name: Schema.String }), + ResultId: (data) => data.id, + execute: (ids) => db.selectFrom("users").where("id", "in", ids).selectAll() + }) + + const todoIds = [1, 2, 3].map((_) => GetUserById.execute(_)) + const result = yield* Effect.all(todoIds, { batching: true }) + assert.deepStrictEqual(result, [ + Option.some({ id: 1, name: "Alice" }), + Option.some({ id: 2, name: "Bob" }), + Option.some({ id: 3, name: "Charlie" }) + ]) + }).pipe(Effect.provide(KyselyLive))) +}) diff --git a/packages/sql-kysely/test/utils.ts b/packages/sql-kysely/test/utils.ts new file mode 100644 index 0000000000..ee766e4383 --- /dev/null +++ b/packages/sql-kysely/test/utils.ts @@ -0,0 +1,80 @@ +import * as Mssql from "@effect/sql-mssql" +import * as Mysql from "@effect/sql-mysql2" +import * as Pg from "@effect/sql-pg" +import type { StartedMSSQLServerContainer } from "@testcontainers/mssqlserver" +import { MSSQLServerContainer } from "@testcontainers/mssqlserver" +import type { StartedMySqlContainer } from "@testcontainers/mysql" +import { MySqlContainer } from "@testcontainers/mysql" +import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql" +import { PostgreSqlContainer } from "@testcontainers/postgresql" +import { Config, Context, Effect, Layer, Redacted } from "effect" + +export class PgContainer extends Context.Tag("test/PgContainer")< + PgContainer, + StartedPostgreSqlContainer +>() { + static Live = Layer.scoped( + this, + Effect.acquireRelease( + Effect.promise(() => new PostgreSqlContainer("postgres:alpine").start()), + (container) => Effect.promise(() => container.stop()) + ) + ) + + static ClientLive = Layer.unwrapEffect( + Effect.gen(function*(_) { + const container = yield* _(PgContainer) + return Pg.PgClient.layer({ + url: Config.succeed(Redacted.make(container.getConnectionUri())) + }) + }) + ).pipe(Layer.provide(this.Live)) +} + +export class MssqlContainer extends Context.Tag("test/MssqlContainer")< + MssqlContainer, + StartedMSSQLServerContainer +>() { + static Live = Layer.scoped( + this, + Effect.acquireRelease( + Effect.promise(() => new MSSQLServerContainer().acceptLicense().start()), + (container) => Effect.promise(() => container.stop()) + ) + ) + + static ClientLive = Layer.unwrapEffect( + Effect.gen(function*(_) { + const container = yield* _(MssqlContainer) + return Mssql.MssqlClient.layer({ + server: Config.succeed(container.getHost()), + port: Config.succeed(container.getPort()), + username: Config.succeed(container.getUsername()), + password: Config.succeed(Redacted.make(container.getPassword())), + database: Config.succeed(container.getDatabase()) + }) + }) + ).pipe(Layer.provide(this.Live)) +} + +export class MysqlContainer extends Context.Tag("test/MysqlContainer")< + MysqlContainer, + StartedMySqlContainer +>() { + static Live = Layer.scoped( + this, + Effect.acquireRelease( + Effect.promise(() => new MySqlContainer("mysql:lts").start()), + (container) => Effect.promise(() => container.stop()) + ) + ) + + static ClientLive = Layer.unwrapEffect( + Effect.gen(function*(_) { + const container = yield* _(MysqlContainer) + return Mysql.MysqlClient.layer({ + url: Config.succeed(Redacted.make(container.getConnectionUri())) + }) + }) + ).pipe(Layer.provide(this.Live)) +} diff --git a/packages/sql-kysely/tsconfig.build.json b/packages/sql-kysely/tsconfig.build.json new file mode 100644 index 0000000000..8a5c6b4d6e --- /dev/null +++ b/packages/sql-kysely/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.src.json", + "references": [ + { "path": "../effect/tsconfig.build.json" }, + { "path": "../sql/tsconfig.build.json" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "outDir": "build/esm", + "declarationDir": "build/dts", + "stripInternal": true + } +} diff --git a/packages/sql-kysely/tsconfig.examples.json b/packages/sql-kysely/tsconfig.examples.json new file mode 100644 index 0000000000..8065521a88 --- /dev/null +++ b/packages/sql-kysely/tsconfig.examples.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["examples"], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "../effect" }, + { "path": "../sql" }, + { "path": "../sql-sqlite-node" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/examples.tsbuildinfo", + "rootDir": "examples", + "noEmit": true + } +} diff --git a/packages/sql-kysely/tsconfig.json b/packages/sql-kysely/tsconfig.json new file mode 100644 index 0000000000..3edbf6b8a5 --- /dev/null +++ b/packages/sql-kysely/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" }, + { "path": "tsconfig.examples.json" } + ] +} diff --git a/packages/sql-kysely/tsconfig.src.json b/packages/sql-kysely/tsconfig.src.json new file mode 100644 index 0000000000..424a843861 --- /dev/null +++ b/packages/sql-kysely/tsconfig.src.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "references": [{ "path": "../effect" }, { "path": "../sql" }], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src", + "outDir": "build/src" + } +} diff --git a/packages/sql-kysely/tsconfig.test.json b/packages/sql-kysely/tsconfig.test.json new file mode 100644 index 0000000000..b0736b7072 --- /dev/null +++ b/packages/sql-kysely/tsconfig.test.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test"], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "../effect" }, + { "path": "../platform-node" }, + { "path": "../sql" }, + { "path": "../sql-mssql" }, + { "path": "../sql-mysql2" }, + { "path": "../sql-pg" }, + { "path": "../sql-sqlite-node" }, + { "path": "../vitest" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "noEmit": true + } +} diff --git a/packages/sql-kysely/vitest.config.ts b/packages/sql-kysely/vitest.config.ts new file mode 100644 index 0000000000..0411095f25 --- /dev/null +++ b/packages/sql-kysely/vitest.config.ts @@ -0,0 +1,6 @@ +import { mergeConfig, type UserConfigExport } from "vitest/config" +import shared from "../../vitest.shared.js" + +const config: UserConfigExport = {} + +export default mergeConfig(shared, config) diff --git a/packages/sql-mssql/src/MssqlClient.ts b/packages/sql-mssql/src/MssqlClient.ts index a4efcce0c0..124523ff1b 100644 --- a/packages/sql-mssql/src/MssqlClient.ts +++ b/packages/sql-mssql/src/MssqlClient.ts @@ -197,7 +197,7 @@ export const make = ( if (values) { for (let i = 0, len = values.length; i < len; i++) { const value = values[i] - const name = numberToAlpha(i) + const name = numberToParamName(i) if (isMssqlParam(value)) { req.addParameter(name, value.i0, value.i1, value.i2) @@ -431,7 +431,7 @@ export const makeCompiler = (transform?: (_: string) => string) => Statement.makeCompiler({ dialect: "mssql", placeholder(_) { - return `@${numberToAlpha(_ - 1)}` + return `@${numberToParamName(_ - 1)}` }, onIdentifier: transform ? function(value, withoutTransform) { @@ -469,14 +469,8 @@ export const makeCompiler = (transform?: (_: string) => string) => const escape = (str: string) => "[" + str.replace(/\]/g, "]]").replace(/\./g, "].[") + "]" -const charCodeA = "a".charCodeAt(0) -function numberToAlpha(n: number) { - let s = "" - while (n >= 0) { - s = String.fromCharCode((n % 26) + charCodeA) + s - n = Math.floor(n / 26) - 1 - } - return s +function numberToParamName(n: number) { + return `${Math.ceil(n + 1)}` } /** diff --git a/packages/sql-mssql/test/Client.test.ts b/packages/sql-mssql/test/Client.test.ts index 88ac2478fc..b027446acf 100644 --- a/packages/sql-mssql/test/Client.test.ts +++ b/packages/sql-mssql/test/Client.test.ts @@ -16,7 +16,7 @@ describe("mssql", () => { it("insert helper", () => { const [query, params] = sql`INSERT INTO ${sql("people")} ${sql.insert({ name: "Tim", age: 10 })}`.compile() expect(query).toEqual( - `INSERT INTO [people] ([name],[age]) VALUES (@a,@b)` + `INSERT INTO [people] ([name],[age]) VALUES (@1,@2)` ) expect(params).toEqual(["Tim", 10]) }) @@ -25,7 +25,7 @@ describe("mssql", () => { const [query, params] = sql`INSERT INTO ${sql("people")} ${sql.insert({ name: "Tim", age: 10 }).returning("*")}` .compile() expect(query).toEqual( - `INSERT INTO [people] ([name],[age]) OUTPUT INSERTED.* VALUES (@a,@b)` + `INSERT INTO [people] ([name],[age]) OUTPUT INSERTED.* VALUES (@1,@2)` ) expect(params).toEqual(["Tim", 10]) }) @@ -38,7 +38,7 @@ describe("mssql", () => { ) }`.compile() expect(query).toEqual( - `UPDATE people SET name = data.name FROM (values (@a),(@b)) AS data([name])` + `UPDATE people SET name = data.name FROM (values (@1),(@2)) AS data([name])` ) expect(params).toEqual(["Tim", "John"]) }) @@ -51,7 +51,7 @@ describe("mssql", () => { ).returning("*") }`.compile() expect(query).toEqual( - `UPDATE people SET name = data.name OUTPUT INSERTED.* FROM (values (@a),(@b)) AS data([name])` + `UPDATE people SET name = data.name OUTPUT INSERTED.* FROM (values (@1),(@2)) AS data([name])` ) expect(params).toEqual(["Tim", "John"]) }) @@ -60,14 +60,14 @@ describe("mssql", () => { const [query, params] = sql`UPDATE people SET ${sql.update({ name: "Tim" }).returning("*")}` .compile() expect(query).toEqual( - `UPDATE people SET [name] = @a OUTPUT INSERTED.*` + `UPDATE people SET [name] = @1 OUTPUT INSERTED.*` ) expect(params).toEqual(["Tim"]) }) it("array helper", () => { const [query, params] = sql`SELECT * FROM ${sql("people")} WHERE id IN ${sql.in([1, 2, "string"])}`.compile() - expect(query).toEqual(`SELECT * FROM [people] WHERE id IN (@a,@b,@c)`) + expect(query).toEqual(`SELECT * FROM [people] WHERE id IN (@1,@2,@3)`) expect(params).toEqual([1, 2, "string"]) }) @@ -78,7 +78,7 @@ describe("mssql", () => { 1 ) }`.compile() - expect(query).toEqual(`SELECT * FROM [people] WHERE id = @a`) + expect(query).toEqual(`SELECT * FROM [people] WHERE id = @1`) expect(isCustom("MssqlParam")(params[0])).toEqual(true) const param = params[0] as unknown as Custom< "MsSqlParam", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaab482da1..034da81e1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -610,12 +610,44 @@ importers: version: 10.10.3 drizzle-orm: specifier: ^0.31.0 - version: 0.31.4(@cloudflare/workers-types@4.20240725.0)(@op-engineering/op-sqlite@6.1.4(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.25.0(@babel/core@7.24.9))(react@18.3.1))(react@18.3.1))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.11)(better-sqlite3@11.1.2)(bun-types@1.1.20)(mysql2@3.11.0)(postgres@3.4.4)(react@18.3.1) + version: 0.31.4(@cloudflare/workers-types@4.20240725.0)(@op-engineering/op-sqlite@6.1.4(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.25.0(@babel/core@7.24.9))(react@18.3.1))(react@18.3.1))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.11)(better-sqlite3@11.1.2)(bun-types@1.1.20)(kysely@0.27.3)(mysql2@3.11.0)(postgres@3.4.4)(react@18.3.1) effect: specifier: workspace:^ version: link:../effect/dist publishDirectory: dist + packages/sql-kysely: + dependencies: + '@opentelemetry/semantic-conventions': + specifier: ^1.24.1 + version: 1.25.1 + devDependencies: + '@effect/sql': + specifier: workspace:^ + version: link:../sql/dist + '@testcontainers/mssqlserver': + specifier: ^10.9.0 + version: 10.10.0 + '@testcontainers/mysql': + specifier: ^10.9.0 + version: 10.10.0 + '@testcontainers/postgresql': + specifier: ^10.9.0 + version: 10.10.0 + '@types/better-sqlite3': + specifier: ^7.6.10 + version: 7.6.10 + better-sqlite3: + specifier: ^11.0.0 + version: 11.1.2 + effect: + specifier: workspace:^ + version: link:../effect/dist + kysely: + specifier: ^0.27.3 + version: 0.27.3 + publishDirectory: dist + packages/sql-mssql: dependencies: '@opentelemetry/semantic-conventions': @@ -924,6 +956,10 @@ packages: resolution: {integrity: sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.24.7': + resolution: {integrity: sha512-LGeMaf5JN4hAT471eJdBs/GK1DoYIJ5GCtZN/EsL6KUiiDZOvO/eKE11AMZJa2zP4zk4qe9V2O/hxAmkRc8p6w==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.24.8': resolution: {integrity: sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==} engines: {node: '>=6.9.0'} @@ -2592,9 +2628,18 @@ packages: resolution: {integrity: sha512-10s/u/Main1RGO+jjzK+mgC/zh1ls1CEnq3Dujr03TwvzLg+j4FAohOmlYkQj8KQOj1vGR9cuB9F8tVBTwVGVA==} hasBin: true + '@testcontainers/mssqlserver@10.10.0': + resolution: {integrity: sha512-zuofGTErmJREWeznYZw//U8K4v5sR8irIlu/iNfso/m6ZXBIGW0COm1oaYd6GMjR9ypo54MsOXJX3lRRyaDzYg==} + + '@testcontainers/mysql@10.10.0': + resolution: {integrity: sha512-D1GFS1ugM9nL1NPGO5A5t9MyBgs9/Vwy1MIk5xX1lTCNLzjtoudL+fb3yK4h7krFX/B3lbn9/uoDSWui3yn4QQ==} + '@testcontainers/mysql@10.10.3': resolution: {integrity: sha512-8mJzCBkl78VNpAlB0tBIzXCI3YgeGWssyPr7LUOH4229KoS1Fs8cdP3G8OOZByuD89YAQNi8EDMUH91G4mTy+w==} + '@testcontainers/postgresql@10.10.0': + resolution: {integrity: sha512-cgzYnhzsYPVwN370bjJCuyAkkjLhGD6EBoQi4j+xKomMU4e6FWu0SYDolP+tiFGKWaS95SsIY3CPp6bnuxBh/A==} + '@testcontainers/postgresql@10.10.3': resolution: {integrity: sha512-k887VJjbbSyHr4eTRVhoBit9A+7WDYx/EU8XdwJ0swuECB1hOjMuvpCX/AlXLk+bD6dNrE/0lvKW6SwqFTXo1A==} @@ -2611,6 +2656,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/better-sqlite3@7.6.10': + resolution: {integrity: sha512-TZBjD+yOsyrUJGmcUj6OS3JADk3+UZcNv3NOBqGkM09bZdi28fNZw8ODqbMOLfKCu7RYCO62/ldq1iHbzxqoPw==} + '@types/better-sqlite3@7.6.11': resolution: {integrity: sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg==} @@ -4777,6 +4825,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kysely@0.27.3: + resolution: {integrity: sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA==} + engines: {node: '>=14.0.0'} + lazy-cache@2.0.2: resolution: {integrity: sha512-7vp2Acd2+Kz4XkzxGxaB1FWOi8KjWIWsgdfD5MCb86DWvlLqhRPM+d6Pro3iNEL5VT9mstz5hKAlcd+QR6H3aA==} engines: {node: '>=0.10.0'} @@ -7072,6 +7124,13 @@ snapshots: dependencies: '@babel/types': 7.24.9 + '@babel/helper-member-expression-to-functions@7.24.7': + dependencies: + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 + transitivePeerDependencies: + - supports-color + '@babel/helper-member-expression-to-functions@7.24.8': dependencies: '@babel/traverse': 7.24.8 @@ -7126,7 +7185,7 @@ snapshots: dependencies: '@babel/core': 7.24.9 '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-member-expression-to-functions': 7.24.8 + '@babel/helper-member-expression-to-functions': 7.24.7 '@babel/helper-optimise-call-expression': 7.24.7 transitivePeerDependencies: - supports-color @@ -9135,6 +9194,20 @@ snapshots: '@sqlite.org/sqlite-wasm@3.46.0-build2': {} + '@testcontainers/mssqlserver@10.10.0': + dependencies: + testcontainers: 10.10.3 + transitivePeerDependencies: + - encoding + - supports-color + + '@testcontainers/mysql@10.10.0': + dependencies: + testcontainers: 10.10.3 + transitivePeerDependencies: + - encoding + - supports-color + '@testcontainers/mysql@10.10.3': dependencies: testcontainers: 10.10.3 @@ -9142,6 +9215,13 @@ snapshots: - encoding - supports-color + '@testcontainers/postgresql@10.10.0': + dependencies: + testcontainers: 10.10.3 + transitivePeerDependencies: + - encoding + - supports-color + '@testcontainers/postgresql@10.10.3': dependencies: testcontainers: 10.10.3 @@ -9166,6 +9246,10 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/better-sqlite3@7.6.10': + dependencies: + '@types/node': 20.14.11 + '@types/better-sqlite3@7.6.11': dependencies: '@types/node': 20.14.11 @@ -10308,7 +10392,7 @@ snapshots: dotenv@8.6.0: {} - drizzle-orm@0.31.4(@cloudflare/workers-types@4.20240725.0)(@op-engineering/op-sqlite@6.1.4(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.25.0(@babel/core@7.24.9))(react@18.3.1))(react@18.3.1))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.11)(better-sqlite3@11.1.2)(bun-types@1.1.20)(mysql2@3.11.0)(postgres@3.4.4)(react@18.3.1): + drizzle-orm@0.31.4(@cloudflare/workers-types@4.20240725.0)(@op-engineering/op-sqlite@6.1.4(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.25.0(@babel/core@7.24.9))(react@18.3.1))(react@18.3.1))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.11)(better-sqlite3@11.1.2)(bun-types@1.1.20)(kysely@0.27.3)(mysql2@3.11.0)(postgres@3.4.4)(react@18.3.1): optionalDependencies: '@cloudflare/workers-types': 4.20240725.0 '@op-engineering/op-sqlite': 6.1.4(react-native@0.74.3(@babel/core@7.24.9)(@babel/preset-env@7.25.0(@babel/core@7.24.9))(react@18.3.1))(react@18.3.1) @@ -10316,6 +10400,7 @@ snapshots: '@types/better-sqlite3': 7.6.11 better-sqlite3: 11.1.2 bun-types: 1.1.20 + kysely: 0.27.3 mysql2: 3.11.0 postgres: 3.4.4 react: 18.3.1 @@ -11616,6 +11701,8 @@ snapshots: kleur@3.0.3: {} + kysely@0.27.3: {} + lazy-cache@2.0.2: dependencies: set-getter: 0.1.1 diff --git a/tsconfig.base.json b/tsconfig.base.json index d996f5c453..cd6b3a6af4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -112,6 +112,9 @@ "@effect/sql-drizzle": ["./packages/sql-drizzle/src/index.js"], "@effect/sql-drizzle/*": ["./packages/sql-drizzle/src/*.js"], "@effect/sql-drizzle/test/*": ["./packages/sql-drizzle/test/*.js"], + "@effect/sql-kysely": ["./packages/sql-kysely/src/index.js"], + "@effect/sql-kysely/*": ["./packages/sql-kysely/src/*.js"], + "@effect/sql-kysely/test/*": ["./packages/sql-kysely/test/*.js"], "@effect/sql-mssql": ["./packages/sql-mssql/src/index.js"], "@effect/sql-mssql/*": ["./packages/sql-mssql/src/*.js"], "@effect/sql-mssql/test/*": ["./packages/sql-mssql/test/*.js"], diff --git a/tsconfig.build.json b/tsconfig.build.json index e3868b2877..1ee4e135fc 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -23,6 +23,7 @@ { "path": "packages/sql/tsconfig.build.json" }, { "path": "packages/sql-d1/tsconfig.build.json" }, { "path": "packages/sql-drizzle/tsconfig.build.json" }, + { "path": "packages/sql-kysely/tsconfig.build.json" }, { "path": "packages/sql-mysql2/tsconfig.build.json" }, { "path": "packages/sql-mssql/tsconfig.build.json" }, { "path": "packages/sql-pg/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index ca8fb9b4a8..ffdca6c967 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ { "path": "packages/sql" }, { "path": "packages/sql-d1" }, { "path": "packages/sql-drizzle" }, + { "path": "packages/sql-kysely" }, { "path": "packages/sql-mssql" }, { "path": "packages/sql-mysql2" }, { "path": "packages/sql-pg" }, diff --git a/vitest.shared.ts b/vitest.shared.ts index 82e5c4547e..cbc7e7cc9b 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -49,6 +49,7 @@ const config: UserConfig = { ...alias("sql"), ...alias("sql-d1"), ...alias("sql-drizzle"), + ...alias("sql-kysely"), ...alias("sql-mssql"), ...alias("sql-mysql2"), ...alias("sql-pg"),