Skip to content

Commit

Permalink
Schema: Fix Duration Encoding to Support All Duration Types (#4313)
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Jan 22, 2025
1 parent f523f1a commit a9c94c8
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 76 deletions.
50 changes: 50 additions & 0 deletions .changeset/six-mangos-think.md
Original file line number Diff line number Diff line change
@@ -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' }
```
14 changes: 13 additions & 1 deletion packages/effect/dtslint/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1763,24 +1763,36 @@ S.BigDecimalFromNumber
// Duration
// ---------------------------------------------

// $ExpectType Schema<Duration, readonly [seconds: number, nanos: number], never>
// $ExpectType Schema<Duration, DurationEncoded, never>
S.asSchema(S.Duration)

// $ExpectType typeof Duration
S.Duration

// ---------------------------------------------
// DurationFromSelf
// ---------------------------------------------

// $ExpectType Schema<Duration, Duration, never>
S.asSchema(S.DurationFromSelf)

// $ExpectType typeof DurationFromSelf
S.DurationFromSelf

// ---------------------------------------------
// DurationFromMillis
// ---------------------------------------------

// $ExpectType Schema<Duration, number, never>
S.asSchema(S.DurationFromMillis)

// $ExpectType typeof DurationFromMillis
S.DurationFromMillis

// ---------------------------------------------
// DurationFromNanos
// ---------------------------------------------

// $ExpectType Schema<Duration, bigint, never>
S.asSchema(S.DurationFromNanos)

Expand Down
69 changes: 52 additions & 17 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly [seconds: number, nanos: number]> = Union(FiniteHRTime, InfiniteHRTime).annotations({
identifier: "HRTime",
description: "a tuple of seconds and nanos to be decoded into a Duration"
const DurationValue: Schema<duration_.DurationValue, DurationEncoded> = 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" }) {}

Expand Down Expand Up @@ -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<any, AST.AST>())
Expand Down
77 changes: 38 additions & 39 deletions packages/effect/test/Schema/Schema/Duration/Duration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
})
})
})
36 changes: 19 additions & 17 deletions packages/effect/test/Schema/Schema/getNumberIndexedAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions packages/effect/test/Schema/TestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ export const assertions = Effect.runSync(
Effect.provideService(SchemaTest.AssertConfig, {
arbitrary: {
validateGeneratedValues: {
skip: true
skip: false
}
},
testRoundtripConsistency: {
skip: true
skip: false
}
})
)
Expand Down

0 comments on commit a9c94c8

Please sign in to comment.