From a9c94c807755610831211a686d2fad849ab38eb4 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 22 Jan 2025 15:30:31 +0100 Subject: [PATCH] Schema: Fix `Duration` Encoding to Support All Duration Types (#4313) --- .changeset/six-mangos-think.md | 50 ++++++++++++ packages/effect/dtslint/Schema.ts | 14 +++- packages/effect/src/Schema.ts | 69 +++++++++++++---- .../Schema/Schema/Duration/Duration.test.ts | 77 +++++++++---------- .../Schema/getNumberIndexedAccess.test.ts | 36 +++++---- packages/effect/test/Schema/TestUtils.ts | 4 +- 6 files changed, 174 insertions(+), 76 deletions(-) create mode 100644 .changeset/six-mangos-think.md diff --git a/.changeset/six-mangos-think.md b/.changeset/six-mangos-think.md new file mode 100644 index 00000000000..c27e826ad08 --- /dev/null +++ b/.changeset/six-mangos-think.md @@ -0,0 +1,50 @@ +--- +"effect": patch +--- + +Schema: Update `Duration` Encoding to a Tagged Union Format. + +This changeset fixes the `Duration` schema to support all possible duration types, including finite, infinite, and nanosecond durations. The encoding format has been updated from a tuple (`readonly [seconds: number, nanos: number]`) to a tagged union. + +This update introduces a change to the encoding format. The previous tuple representation is replaced with a more expressive tagged union, which accommodates all duration types: + +```ts +type DurationEncoded = + | { + readonly _tag: "Millis" + readonly millis: number + } + | { + readonly _tag: "Nanos" + readonly nanos: string + } + | { + readonly _tag: "Infinity" + } +``` + +**Rationale** + +The `Duration` schema is primarily used to encode durations for transmission. The new tagged union format ensures clear and precise encoding for: + +- Finite durations, such as milliseconds. +- Infinite durations, such as `Duration.infinity`. +- Nanosecond durations. + +**Example** + +```ts +import { Duration, Schema } from "effect" + +// Encoding a finite duration in milliseconds +console.log(Schema.encodeSync(Schema.Duration)(Duration.millis(1000))) +// Output: { _tag: 'Millis', millis: 1000 } + +// Encoding an infinite duration +console.log(Schema.encodeSync(Schema.Duration)(Duration.infinity)) +// Output: { _tag: 'Infinity' } + +// Encoding a duration in nanoseconds +console.log(Schema.encodeSync(Schema.Duration)(Duration.nanos(1000n))) +// Output: { _tag: 'Nanos', nanos: '1000' } +``` diff --git a/packages/effect/dtslint/Schema.ts b/packages/effect/dtslint/Schema.ts index 1d3afc1ff07..b625c7593bb 100644 --- a/packages/effect/dtslint/Schema.ts +++ b/packages/effect/dtslint/Schema.ts @@ -1763,24 +1763,36 @@ S.BigDecimalFromNumber // Duration // --------------------------------------------- -// $ExpectType Schema +// $ExpectType Schema S.asSchema(S.Duration) // $ExpectType typeof Duration S.Duration +// --------------------------------------------- +// DurationFromSelf +// --------------------------------------------- + // $ExpectType Schema S.asSchema(S.DurationFromSelf) // $ExpectType typeof DurationFromSelf S.DurationFromSelf +// --------------------------------------------- +// DurationFromMillis +// --------------------------------------------- + // $ExpectType Schema S.asSchema(S.DurationFromMillis) // $ExpectType typeof DurationFromMillis S.DurationFromMillis +// --------------------------------------------- +// DurationFromNanos +// --------------------------------------------- + // $ExpectType Schema S.asSchema(S.DurationFromNanos) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 364678706fd..2b49cde632d 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -5719,34 +5719,68 @@ export class DurationFromMillis extends transform( } ).annotations({ identifier: "DurationFromMillis" }) {} -const FiniteHRTime = Tuple( - element(NonNegativeInt).annotations({ title: "seconds" }), - element(NonNegativeInt).annotations({ title: "nanos" }) -).annotations({ identifier: "FiniteHRTime" }) +const DurationValueMillis = TaggedStruct("Millis", { millis: NonNegativeInt }) +const DurationValueNanos = TaggedStruct("Nanos", { nanos: BigInt$ }) +const DurationValueInfinity = TaggedStruct("Infinity", {}) +const durationValueInfinity = DurationValueInfinity.make({}) -const InfiniteHRTime = Tuple(Literal(-1), Literal(0)).annotations({ identifier: "InfiniteHRTime" }) +/** + * @category Duration utils + * @since 3.12.8 + */ +export type DurationEncoded = + | { + readonly _tag: "Millis" + readonly millis: number + } + | { + readonly _tag: "Nanos" + readonly nanos: string + } + | { + readonly _tag: "Infinity" + } -const HRTime: Schema = Union(FiniteHRTime, InfiniteHRTime).annotations({ - identifier: "HRTime", - description: "a tuple of seconds and nanos to be decoded into a Duration" +const DurationValue: Schema = Union( + DurationValueMillis, + DurationValueNanos, + DurationValueInfinity +).annotations({ + identifier: "DurationValue", + description: "an JSON-compatible tagged union to be decoded into a Duration" }) /** - * A schema that transforms a `[number, number]` tuple into a `Duration`. - * - * Infinite durations are encoded as `[-1, 0]`. + * A schema that converts a JSON-compatible tagged union into a `Duration`. * * @category Duration transformations * @since 3.10.0 */ export class Duration extends transform( - HRTime, + DurationValue, DurationFromSelf, { strict: true, - decode: ([seconds, nanos]) => - seconds === -1 ? duration_.infinity : duration_.nanos(BigInt(seconds) * BigInt(1e9) + BigInt(nanos)), - encode: (duration) => duration.value._tag === "Infinity" ? [-1, 0] as const : duration_.toHrTime(duration) + decode: (input) => { + switch (input._tag) { + case "Millis": + return duration_.millis(input.millis) + case "Nanos": + return duration_.nanos(input.nanos) + case "Infinity": + return duration_.infinity + } + }, + encode: (duration) => { + switch (duration.value._tag) { + case "Millis": + return DurationValueMillis.make({ millis: duration.value.millis }) + case "Nanos": + return DurationValueNanos.make({ nanos: duration.value.nanos }) + case "Infinity": + return durationValueInfinity + } + } } ).annotations({ identifier: "Duration" }) {} @@ -8513,8 +8547,9 @@ type MakeOptions = boolean | { readonly disableValidation?: boolean } -const getDisableValidationMakeOption = (options: MakeOptions | undefined): boolean => - Predicate.isBoolean(options) ? options : options?.disableValidation ?? false +function getDisableValidationMakeOption(options: MakeOptions | undefined): boolean { + return Predicate.isBoolean(options) ? options : options?.disableValidation ?? false +} const astCache = globalValue("effect/Schema/astCache", () => new WeakMap()) diff --git a/packages/effect/test/Schema/Schema/Duration/Duration.test.ts b/packages/effect/test/Schema/Schema/Duration/Duration.test.ts index 6cf9aae2d9d..2ef8838f4ba 100644 --- a/packages/effect/test/Schema/Schema/Duration/Duration.test.ts +++ b/packages/effect/test/Schema/Schema/Duration/Duration.test.ts @@ -6,71 +6,70 @@ import { describe, it } from "vitest" describe("Duration", () => { const schema = S.Duration - it.todo("test roundtrip consistency", () => { + it("test roundtrip consistency", () => { Util.assertions.testRoundtripConsistency(schema) }) it("decoding", async () => { - await Util.assertions.decoding.succeed(schema, [-1, 0], Duration.infinity) - await Util.assertions.decoding.succeed(schema, [555, 123456789], Duration.nanos(555123456789n)) + await Util.assertions.decoding.succeed(schema, { _tag: "Infinity" }, Duration.infinity) + await Util.assertions.decoding.succeed(schema, { _tag: "Millis", millis: 12345 }, Duration.millis(12345)) + await Util.assertions.decoding.succeed(schema, { _tag: "Nanos", nanos: "54321" }, Duration.nanos(54321n)) + await Util.assertions.decoding.fail( schema, - [-500, 0], + null, `Duration └─ Encoded side transformation failure - └─ HRTime - ├─ InfiniteHRTime - │ └─ ["0"] - │ └─ Expected -1, actual -500 - └─ FiniteHRTime - └─ [0] - └─ NonNegativeInt - └─ From side refinement failure - └─ NonNegative - └─ Predicate refinement failure - └─ Expected a non-negative number, actual -500` + └─ Expected DurationValue, actual null` ) + await Util.assertions.decoding.fail( schema, - [0, -123], + {}, `Duration └─ Encoded side transformation failure - └─ HRTime - ├─ InfiniteHRTime - │ └─ ["0"] - │ └─ Expected -1, actual 0 - └─ FiniteHRTime - └─ [1] - └─ NonNegativeInt - └─ From side refinement failure - └─ NonNegative - └─ Predicate refinement failure - └─ Expected a non-negative number, actual -123` + └─ DurationValue + └─ { readonly _tag: "Millis" | "Nanos" | "Infinity" } + └─ ["_tag"] + └─ is missing` ) + await Util.assertions.decoding.fail( schema, - 123, + { _tag: "Millis", millis: -1 }, `Duration └─ Encoded side transformation failure - └─ HRTime - ├─ Expected InfiniteHRTime, actual 123 - └─ Expected FiniteHRTime, actual 123` + └─ DurationValue + └─ { readonly _tag: "Millis"; readonly millis: NonNegativeInt } + └─ ["millis"] + └─ NonNegativeInt + └─ From side refinement failure + └─ NonNegative + └─ Predicate refinement failure + └─ Expected a non-negative number, actual -1` ) + await Util.assertions.decoding.fail( schema, - 123n, + { _tag: "Nanos", nanos: null }, `Duration └─ Encoded side transformation failure - └─ HRTime - ├─ Expected InfiniteHRTime, actual 123n - └─ Expected FiniteHRTime, actual 123n` + └─ DurationValue + └─ { readonly _tag: "Nanos"; readonly nanos: BigInt } + └─ ["nanos"] + └─ BigInt + └─ Encoded side transformation failure + └─ Expected string, actual null` ) }) it("encoding", async () => { - await Util.assertions.encoding.succeed(schema, Duration.infinity, [-1, 0]) - await Util.assertions.encoding.succeed(schema, Duration.seconds(5), [5, 0]) - await Util.assertions.encoding.succeed(schema, Duration.millis(123456789), [123456, 789000000]) - await Util.assertions.encoding.succeed(schema, Duration.nanos(555123456789n), [555, 123456789]) + await Util.assertions.encoding.succeed(schema, Duration.infinity, { _tag: "Infinity" }) + await Util.assertions.encoding.succeed(schema, Duration.seconds(5), { _tag: "Millis", millis: 5000 }) + await Util.assertions.encoding.succeed(schema, Duration.millis(123456789), { _tag: "Millis", millis: 123456789 }) + await Util.assertions.encoding.succeed(schema, Duration.nanos(555123456789n), { + _tag: "Nanos", + nanos: "555123456789" + }) }) }) diff --git a/packages/effect/test/Schema/Schema/getNumberIndexedAccess.test.ts b/packages/effect/test/Schema/Schema/getNumberIndexedAccess.test.ts index 343092991da..f2cd3c83502 100644 --- a/packages/effect/test/Schema/Schema/getNumberIndexedAccess.test.ts +++ b/packages/effect/test/Schema/Schema/getNumberIndexedAccess.test.ts @@ -4,31 +4,33 @@ import * as Util from "effect/test/Schema/TestUtils" import { describe, it } from "vitest" describe("getNumberIndexedAccess", () => { - it("tuple", async () => { - const schema = S.getNumberIndexedAccess(S.Tuple(S.NumberFromString, S.Duration)) - await Util.assertions.decoding.succeed(schema, "1", 1) - await Util.assertions.decoding.succeed(schema, [1, 0], Duration.nanos(1000000000n)) - await Util.assertions.encoding.succeed(schema, 1, "1") - await Util.assertions.encoding.succeed(schema, Duration.nanos(1000000000n), [1, 0]) - }) + describe("Tuple", () => { + it("decodes and encodes required elements in a tuple", async () => { + const schema = S.getNumberIndexedAccess(S.Tuple(S.NumberFromString, S.DurationFromNanos)) + await Util.assertions.decoding.succeed(schema, "1", 1) + await Util.assertions.decoding.succeed(schema, 1n, Duration.nanos(1n)) + await Util.assertions.encoding.succeed(schema, 1, "1") + await Util.assertions.encoding.succeed(schema, Duration.nanos(1n), 1n) + }) - it("tuple with optional element", async () => { - const schema = S.getNumberIndexedAccess(S.Tuple(S.NumberFromString, S.optionalElement(S.Duration))) - await Util.assertions.decoding.succeed(schema, undefined) - await Util.assertions.decoding.succeed(schema, "1", 1) - await Util.assertions.decoding.succeed(schema, [1, 0], Duration.nanos(1000000000n)) - await Util.assertions.encoding.succeed(schema, undefined, undefined) - await Util.assertions.encoding.succeed(schema, 1, "1") - await Util.assertions.encoding.succeed(schema, Duration.nanos(1000000000n), [1, 0]) + it("decodes and encodes a tuple with an optional element", async () => { + const schema = S.getNumberIndexedAccess(S.Tuple(S.NumberFromString, S.optionalElement(S.DurationFromNanos))) + await Util.assertions.decoding.succeed(schema, undefined) + await Util.assertions.decoding.succeed(schema, "1", 1) + await Util.assertions.decoding.succeed(schema, 1n, Duration.nanos(1n)) + await Util.assertions.encoding.succeed(schema, undefined, undefined) + await Util.assertions.encoding.succeed(schema, 1, "1") + await Util.assertions.encoding.succeed(schema, Duration.nanos(1n), 1n) + }) }) - it("array", async () => { + it("Array", async () => { const schema = S.getNumberIndexedAccess(S.Array(S.NumberFromString)) await Util.assertions.decoding.succeed(schema, "1", 1) await Util.assertions.encoding.succeed(schema, 1, "1") }) - it("union", async () => { + it("Union", async () => { const schema = S.getNumberIndexedAccess(S.Union(S.Array(S.Number), S.Array(S.String))) await Util.assertions.decoding.succeed(schema, "a") await Util.assertions.decoding.succeed(schema, 1) diff --git a/packages/effect/test/Schema/TestUtils.ts b/packages/effect/test/Schema/TestUtils.ts index b51b352a6db..b862833695c 100644 --- a/packages/effect/test/Schema/TestUtils.ts +++ b/packages/effect/test/Schema/TestUtils.ts @@ -18,11 +18,11 @@ export const assertions = Effect.runSync( Effect.provideService(SchemaTest.AssertConfig, { arbitrary: { validateGeneratedValues: { - skip: true + skip: false } }, testRoundtripConsistency: { - skip: true + skip: false } }) )