From a84a7a6dbd1d94d10c09877bdfd8d50a6d8f0072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Fran=C3=A7ois?= Date: Tue, 3 Dec 2024 21:20:19 +0100 Subject: [PATCH 01/15] feat: add runtime type extractor (#4020) --- .changeset/eight-rocks-hide.md | 5 +++++ packages/effect/dtslint/Runtime.ts | 4 ++++ packages/effect/src/Runtime.ts | 11 +++++++++++ 3 files changed, 20 insertions(+) create mode 100644 .changeset/eight-rocks-hide.md create mode 100644 packages/effect/dtslint/Runtime.ts diff --git a/.changeset/eight-rocks-hide.md b/.changeset/eight-rocks-hide.md new file mode 100644 index 00000000000..b01ba51ee40 --- /dev/null +++ b/.changeset/eight-rocks-hide.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add Runtime.Runtime.Context type extractor diff --git a/packages/effect/dtslint/Runtime.ts b/packages/effect/dtslint/Runtime.ts new file mode 100644 index 00000000000..72f052fbf7f --- /dev/null +++ b/packages/effect/dtslint/Runtime.ts @@ -0,0 +1,4 @@ +import type * as Runtime from "effect/Runtime" + +// $ExpectType { foo: string; } +export type ContextOfRuntime = Runtime.Runtime.Context> diff --git a/packages/effect/src/Runtime.ts b/packages/effect/src/Runtime.ts index f1db056bb3d..6c3ae01fc2f 100644 --- a/packages/effect/src/Runtime.ts +++ b/packages/effect/src/Runtime.ts @@ -52,6 +52,17 @@ export interface Runtime extends Pipeable { readonly fiberRefs: FiberRefs.FiberRefs } +/** + * @since 3.12.0 + */ +export declare namespace Runtime { + /** + * @since 3.12.0 + * @category Type Extractors + */ + export type Context> = [T] extends [Runtime] ? R : never +} + /** * @since 2.0.0 * @category models From f403d917b36e06b915f9516f85584092b994db56 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 6 Dec 2024 12:11:19 +1300 Subject: [PATCH 02/15] add non-traced overload to Effect.fn (#4077) --- .changeset/fifty-mice-dream.md | 5 ++ packages/effect/src/Effect.ts | 94 +++++++++++++++++++++------------- 2 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 .changeset/fifty-mice-dream.md diff --git a/.changeset/fifty-mice-dream.md b/.changeset/fifty-mice-dream.md new file mode 100644 index 00000000000..a7c1839dfa3 --- /dev/null +++ b/.changeset/fifty-mice-dream.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add non-traced overload to Effect.fn diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index 4a7cacdbb7c..cee4d82cde2 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -11569,49 +11569,71 @@ export namespace fn { * @since 3.11.0 * @category function */ -export const fn: ( - name: string, - options?: Tracer.SpanOptions -) => fn.Gen & fn.NonGen = (name, options) => (body: Function, ...pipeables: Array) => { - return function(this: any, ...args: Array) { - const limit = Error.stackTraceLimit - Error.stackTraceLimit = 2 - const error = new Error() - Error.stackTraceLimit = limit - let cache: false | string = false - const captureStackTrace = () => { - if (cache !== false) { - return cache - } - if (error.stack) { - const stack = error.stack.trim().split("\n") - cache = stack.slice(2).join("\n").trim() - return cache +export const fn: + & fn.Gen + & fn.NonGen + & (( + name: string, + options?: Tracer.SpanOptions + ) => fn.Gen & fn.NonGen) = function(nameOrBody: Function | string, ...pipeables: Array) { + if (typeof nameOrBody !== "string") { + return function(this: any) { + return fnApply(this, nameOrBody, arguments as any, pipeables) + } as any + } + const name = nameOrBody + const options = pipeables[0] + return (body: Function, ...pipeables: Array) => { + return function(this: any, ...args: Array) { + const limit = Error.stackTraceLimit + Error.stackTraceLimit = 2 + const error = new Error() + Error.stackTraceLimit = limit + let cache: false | string = false + const captureStackTrace = () => { + if (cache !== false) { + return cache + } + if (error.stack) { + const stack = error.stack.trim().split("\n") + cache = stack.slice(2).join("\n").trim() + return cache + } + } + const effect = fnApply(this, body, args, pipeables) + const opts: any = (options && "captureStackTrace" in options) ? options : { captureStackTrace, ...options } + return withSpan(effect, name, opts) } } - let effect: Effect - let fnError: any = undefined + } + +function fnApply(self: any, body: Function, args: Array, pipeables: Array) { + let effect: Effect + let fnError: any = undefined + if (isGeneratorFunction(body)) { + effect = gen(() => internalCall(() => body.apply(self, args))) + } else { try { - effect = isGeneratorFunction(body) - ? gen(() => internalCall(() => body.apply(this, args))) - : body.apply(this, args) + effect = body.apply(self, args) } catch (error) { fnError = error effect = die(error) } - try { - for (const x of pipeables) { - effect = x(effect) - } - } catch (error) { - effect = fnError - ? failCause(internalCause.sequential( - internalCause.die(fnError), - internalCause.die(error) - )) - : die(error) + } + if (pipeables.length === 0) { + return effect + } + try { + for (const x of pipeables) { + effect = x(effect) } - const opts: any = (options && "captureStackTrace" in options) ? options : { captureStackTrace, ...options } - return withSpan(effect, name, opts) + } catch (error) { + effect = fnError + ? failCause(internalCause.sequential( + internalCause.die(fnError), + internalCause.die(error) + )) + : die(error) } + return effect } From 5ff1da20c0ad907e38be1604f618d173e89dbc5a Mon Sep 17 00:00:00 2001 From: Titouan CREACH Date: Fri, 6 Dec 2024 12:49:14 +0100 Subject: [PATCH 03/15] add Schema.headNonEmpty on Schema.NonEmptyArray (#3983) Co-authored-by: Giulio Canti --- .changeset/violet-spoons-compare.md | 5 ++++ packages/effect/src/Schema.ts | 13 ++++++++++ .../Schema/Schema/Array/headNonEmpty.test.ts | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 .changeset/violet-spoons-compare.md create mode 100644 packages/effect/test/Schema/Schema/Array/headNonEmpty.test.ts diff --git a/.changeset/violet-spoons-compare.md b/.changeset/violet-spoons-compare.md new file mode 100644 index 00000000000..f5e26fb5a08 --- /dev/null +++ b/.changeset/violet-spoons-compare.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add Schema.headNonEmpty for Schema.NonEmptyArray diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 7803c3cd48e..395a36c9127 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -6056,6 +6056,19 @@ export const head = (self: Schema, I, R>): SchemaClass { strict: true, decode: array_.head, encode: option_.match({ onNone: () => [], onSome: array_.of }) } ) +/** + * Get the first element of a `NonEmptyReadonlyArray`. + * + * @category NonEmptyReadonlyArray transformations + * @since 3.12.0 + */ +export const headNonEmpty = (self: Schema, I, R>): SchemaClass => + transform( + self, + getNumberIndexedAccess(typeSchema(self)), + { strict: true, decode: array_.headNonEmpty, encode: array_.of } + ) + /** * Retrieves the first element of a `ReadonlyArray`. * diff --git a/packages/effect/test/Schema/Schema/Array/headNonEmpty.test.ts b/packages/effect/test/Schema/Schema/Array/headNonEmpty.test.ts new file mode 100644 index 00000000000..51e113134de --- /dev/null +++ b/packages/effect/test/Schema/Schema/Array/headNonEmpty.test.ts @@ -0,0 +1,26 @@ +import * as S from "effect/Schema" +import * as Util from "effect/test/Schema/TestUtils" +import { describe, it } from "vitest" + +describe("headNonEmpty", () => { + it("decoding", async () => { + const schema = S.headNonEmpty(S.NonEmptyArray(S.NumberFromString)) + await Util.expectDecodeUnknownSuccess(schema, ["1"], 1) + await Util.expectDecodeUnknownFailure( + schema, + ["a"], + `(readonly [NumberFromString, ...NumberFromString[]] <-> number | number) +└─ Encoded side transformation failure + └─ readonly [NumberFromString, ...NumberFromString[]] + └─ [0] + └─ NumberFromString + └─ Transformation process failure + └─ Expected NumberFromString, actual "a"` + ) + }) + + it("encoding", async () => { + const schema = S.headNonEmpty(S.NonEmptyArray(S.NumberFromString)) + await Util.expectEncodeSuccess(schema, 1, ["1"]) + }) +}) From 33c14794727acc93b9ce157e8e70aae8413ce6c3 Mon Sep 17 00:00:00 2001 From: Michael Arnaldi Date: Tue, 10 Dec 2024 20:57:15 +0100 Subject: [PATCH 04/15] Allow passing fast-check parameters to prop function (#4099) --- .changeset/four-ghosts-decide.md | 5 +++ .changeset/hot-jeans-kiss.md | 19 ++++++++++ package.json | 2 +- packages/effect/package.json | 2 +- packages/vitest/src/index.ts | 7 ++-- packages/vitest/src/internal.ts | 24 +++++++++---- .../test/advent-of-pbt-2024/day-1.test.ts | 35 +++++++++++++++++++ packages/vitest/test/index.test.ts | 17 +++++---- pnpm-lock.yaml | 14 ++++---- 9 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 .changeset/four-ghosts-decide.md create mode 100644 .changeset/hot-jeans-kiss.md create mode 100644 packages/vitest/test/advent-of-pbt-2024/day-1.test.ts diff --git a/.changeset/four-ghosts-decide.md b/.changeset/four-ghosts-decide.md new file mode 100644 index 00000000000..8b6dac86492 --- /dev/null +++ b/.changeset/four-ghosts-decide.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Update fast-check to latest version diff --git a/.changeset/hot-jeans-kiss.md b/.changeset/hot-jeans-kiss.md new file mode 100644 index 00000000000..602519dd5ce --- /dev/null +++ b/.changeset/hot-jeans-kiss.md @@ -0,0 +1,19 @@ +--- +"@effect/vitest": patch +--- + +Allow passing fast-check parameters to prop function + +```ts +it.effect.prop( + "adds context", + [realNumber], + ([num]) => + Effect.gen(function* () { + const foo = yield* Foo + expect(foo).toEqual("foo") + return num === num + }), + { fastCheck: { numRuns: 200 } } +) +``` diff --git a/package.json b/package.json index 5324fc0da06..e36d584cf45 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "eslint-plugin-import": "^2.30.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-sort-destructure-keys": "^2.0.0", - "fast-check": "^3.21.0", + "fast-check": "^3.23.1", "glob": "^11.0.0", "jscodeshift": "^0.16.1", "madge": "^8.0.0", diff --git a/packages/effect/package.json b/packages/effect/package.json index d5589b9c82d..b8ab2a7ea2b 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -50,6 +50,6 @@ "zod": "^3.23.5" }, "dependencies": { - "fast-check": "^3.21.0" + "fast-check": "^3.23.1" } } diff --git a/packages/vitest/src/index.ts b/packages/vitest/src/index.ts index dbed927fddc..fd30ba898c8 100644 --- a/packages/vitest/src/index.ts +++ b/packages/vitest/src/index.ts @@ -3,6 +3,7 @@ */ import type * as Duration from "effect/Duration" import type * as Effect from "effect/Effect" +import type * as FC from "effect/FastCheck" import type * as Layer from "effect/Layer" import type * as Schema from "effect/Schema" import type * as Scope from "effect/Scope" @@ -66,7 +67,7 @@ export namespace Vitest { R, [{ [K in keyof S]: Schema.Schema.Type }, V.TaskContext> & V.TestContext] >, - timeout?: number | V.TestOptions + timeout?: number | V.TestOptions & { fastCheck?: FC.Parameters<{ [K in keyof S]: Schema.Schema.Type }> } ) => void } @@ -99,7 +100,7 @@ export namespace Vitest { schemas: { [K in keyof S]: Schema.Schema.Type }, ctx: V.TaskContext> & V.TestContext ) => void, - timeout?: number | V.TestOptions + timeout?: number | V.TestOptions & { fastCheck?: FC.Parameters<{ [K in keyof S]: Schema.Schema.Type }> } ) => void } } @@ -195,7 +196,7 @@ export const prop: ( schemas: { [K in keyof S]: Schema.Schema.Type }, ctx: V.TaskContext> & V.TestContext ) => void, - timeout?: number | V.TestOptions + timeout?: number | V.TestOptions & { fastCheck?: FC.Parameters<{ [K in keyof S]: Schema.Schema.Type }> } ) => void = internal.prop /** diff --git a/packages/vitest/src/internal.ts b/packages/vitest/src/internal.ts index c12b4b657b9..fb61e024da3 100644 --- a/packages/vitest/src/internal.ts +++ b/packages/vitest/src/internal.ts @@ -12,6 +12,7 @@ import * as Fiber from "effect/Fiber" import { flow, identity, pipe } from "effect/Function" import * as Layer from "effect/Layer" import * as Logger from "effect/Logger" +import { isObject } from "effect/Predicate" import * as Schedule from "effect/Schedule" import * as Scope from "effect/Scope" import * as TestEnvironment from "effect/TestContext" @@ -102,8 +103,13 @@ const makeTester = ( const arbs = schemaObj.map((schema) => Arbitrary.make(schema)) return V.it( name, - // @ts-ignore - (ctx) => fc.assert(fc.asyncProperty(...arbs, (...as) => run(ctx, [as as any, ctx], self))), + (ctx) => + // @ts-ignore + fc.assert( + // @ts-ignore + fc.asyncProperty(...arbs, (...as) => run(ctx, [as as any, ctx], self)), + isObject(timeout) ? timeout?.fastCheck : {} + ), timeout ) } @@ -117,8 +123,14 @@ const makeTester = ( return V.it( name, - // @ts-ignore - (ctx) => fc.assert(fc.asyncProperty(arbs, (...as) => run(ctx, [as[0] as any, ctx], self))), + (ctx) => + // @ts-ignore + fc.assert( + fc.asyncProperty(arbs, (...as) => + // @ts-ignore + run(ctx, [as[0] as any, ctx], self)), + isObject(timeout) ? timeout?.fastCheck : {} + ), timeout ) } @@ -132,7 +144,7 @@ export const prop: Vitest.Vitest.Methods["prop"] = (name, schemaObj, self, timeo return V.it( name, // @ts-ignore - (ctx) => fc.assert(fc.property(...arbs, (...as) => self(as, ctx))), + (ctx) => fc.assert(fc.property(...arbs, (...as) => self(as, ctx)), isObject(timeout) ? timeout?.fastCheck : {}), timeout ) } @@ -147,7 +159,7 @@ export const prop: Vitest.Vitest.Methods["prop"] = (name, schemaObj, self, timeo return V.it( name, // @ts-ignore - (ctx) => fc.assert(fc.property(arbs, (...as) => self(as[0], ctx))), + (ctx) => fc.assert(fc.property(arbs, (...as) => self(as[0], ctx)), check), timeout ) } diff --git a/packages/vitest/test/advent-of-pbt-2024/day-1.test.ts b/packages/vitest/test/advent-of-pbt-2024/day-1.test.ts new file mode 100644 index 00000000000..0ecc6dab57e --- /dev/null +++ b/packages/vitest/test/advent-of-pbt-2024/day-1.test.ts @@ -0,0 +1,35 @@ +import { it } from "@effect/vitest" +import { Schema } from "effect" + +class Letter extends Schema.Class("Letter")({ + name: Schema.String.pipe( + Schema.minLength(1), + Schema.filter((s) => s.match(/^[a-z]+$/) !== null) + ), + age: Schema.Int.pipe( + Schema.between(1, 77) + ) +}) { + static Array = Schema.Array(this) +} + +function sortLetters(letters: Schema.Schema.Type) { + const clonedLetters = [...letters] + return clonedLetters.sort((la, lb) => la.age - lb.age || la.name.codePointAt(0)! - lb.name.codePointAt(0)!) +} + +it.prop( + "day #1: should properly sort letters", + [Letter.Array], + ([unsortedLetters]) => { + const letters = sortLetters(unsortedLetters) + for (let i = 1; i < letters.length; ++i) { + const prev = letters[i - 1] + const curr = letters[i] + if (prev.age < curr.age) continue // properly ordered + if (prev.age > curr.age) throw new Error("Invalid on age") + if (prev.name > curr.name) throw new Error("Invalid on name") + } + }, + { fails: true, fastCheck: { seed: 1485455336, path: "352:3:7:9:9:13:12:11", endOnFailure: true } } +) diff --git a/packages/vitest/test/index.test.ts b/packages/vitest/test/index.test.ts index 4c58da1defd..9c5c73169d0 100644 --- a/packages/vitest/test/index.test.ts +++ b/packages/vitest/test/index.test.ts @@ -147,12 +147,17 @@ layer(Foo.Live)("layer", (it) => { })) }) - it.effect.prop("adds context", [realNumber], ([num]) => - Effect.gen(function*() { - const foo = yield* Foo - expect(foo).toEqual("foo") - return num === num - })) + it.effect.prop( + "adds context", + [realNumber], + ([num]) => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + return num === num + }), + { fastCheck: { numRuns: 200 } } + ) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c650828767f..faca6345325 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,8 +125,8 @@ importers: specifier: ^2.0.0 version: 2.0.0(eslint@9.9.1) fast-check: - specifier: ^3.21.0 - version: 3.21.0 + specifier: ^3.23.1 + version: 3.23.1 glob: specifier: ^11.0.0 version: 11.0.0 @@ -303,8 +303,8 @@ importers: packages/effect: dependencies: fast-check: - specifier: ^3.21.0 - version: 3.21.0 + specifier: ^3.23.1 + version: 3.23.1 devDependencies: '@types/jscodeshift': specifier: ^0.11.11 @@ -4680,8 +4680,8 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} - fast-check@3.21.0: - resolution: {integrity: sha512-QpmbiqRFRZ+SIlBJh6xi5d/PgXciUc/xWKc4Vi2RWEHHIRx6oM3f0fWNna++zP9VB5HUBTObUK9gTKQP3vVcrQ==} + fast-check@3.23.1: + resolution: {integrity: sha512-u/MudsoQEgBUZgR5N1v87vEgybeVYus9VnDVaIkxkkGP2jt54naghQ3PCQHJiogS8U/GavZCUPFfx3Xkp+NaHw==} engines: {node: '>=8.0.0'} fast-deep-equal@3.1.3: @@ -12567,7 +12567,7 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 - fast-check@3.21.0: + fast-check@3.23.1: dependencies: pure-rand: 6.1.0 From ae899978e3a97a6adfb50ab10f2b7fe7a79da448 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 10 Dec 2024 21:01:39 +0100 Subject: [PATCH 05/15] add second granularity to Cron (#4088) --- .changeset/khaki-suns-reflect.md | 5 ++ packages/effect/src/Cron.ts | 69 ++++++++++++++++++------ packages/effect/src/Schedule.ts | 4 +- packages/effect/src/internal/schedule.ts | 4 +- packages/effect/test/Cron.test.ts | 5 ++ packages/effect/test/Schedule.test.ts | 29 +++++++++- 6 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 .changeset/khaki-suns-reflect.md diff --git a/.changeset/khaki-suns-reflect.md b/.changeset/khaki-suns-reflect.md new file mode 100644 index 00000000000..97bc0045f3b --- /dev/null +++ b/.changeset/khaki-suns-reflect.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Added support for `second` granularity to `Cron`. diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 46435fbe9f8..1e07f61ddc5 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -36,6 +36,7 @@ export type TypeId = typeof TypeId export interface Cron extends Pipeable, Equal.Equal, Inspectable { readonly [TypeId]: TypeId readonly tz: Option.Option + readonly seconds: ReadonlySet readonly minutes: ReadonlySet readonly hours: ReadonlySet readonly days: ReadonlySet @@ -43,6 +44,7 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable { readonly weekdays: ReadonlySet /** @internal */ readonly first: { + readonly second: number readonly minute: number readonly hour: number readonly day: number @@ -51,6 +53,7 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable { } /** @internal */ readonly next: { + readonly second: ReadonlyArray readonly minute: ReadonlyArray readonly hour: ReadonlyArray readonly day: ReadonlyArray @@ -67,6 +70,7 @@ const CronProto = { [Hash.symbol](this: Cron): number { return pipe( Hash.hash(this.tz), + Hash.combine(Hash.array(Arr.fromIterable(this.seconds))), Hash.combine(Hash.array(Arr.fromIterable(this.minutes))), Hash.combine(Hash.array(Arr.fromIterable(this.hours))), Hash.combine(Hash.array(Arr.fromIterable(this.days))), @@ -82,6 +86,7 @@ const CronProto = { return { _id: "Cron", tz: this.tz, + seconds: Arr.fromIterable(this.seconds), minutes: Arr.fromIterable(this.minutes), hours: Arr.fromIterable(this.hours), days: Arr.fromIterable(this.days), @@ -116,6 +121,7 @@ export const isCron = (u: unknown): u is Cron => hasProperty(u, TypeId) * @category constructors */ export const make = (values: { + readonly seconds?: Iterable | undefined readonly minutes: Iterable readonly hours: Iterable readonly days: Iterable @@ -124,6 +130,7 @@ export const make = (values: { readonly tz?: DateTime.TimeZone | undefined }): Cron => { const o: Mutable = Object.create(CronProto) + o.seconds = new Set(Arr.sort(values.seconds ?? [0], N.Order)) o.minutes = new Set(Arr.sort(values.minutes, N.Order)) o.hours = new Set(Arr.sort(values.hours, N.Order)) o.days = new Set(Arr.sort(values.days, N.Order)) @@ -131,6 +138,7 @@ export const make = (values: { o.weekdays = new Set(Arr.sort(values.weekdays, N.Order)) o.tz = Option.fromNullable(values.tz) + const seconds = Array.from(o.seconds) const minutes = Array.from(o.minutes) const hours = Array.from(o.hours) const days = Array.from(o.days) @@ -138,6 +146,7 @@ export const make = (values: { const weekdays = Array.from(o.weekdays) o.first = { + second: seconds[0] ?? 0, minute: minutes[0] ?? 0, hour: hours[0] ?? 0, day: days[0] ?? 1, @@ -146,6 +155,7 @@ export const make = (values: { } o.next = { + second: nextLookupTable(seconds, 60), minute: nextLookupTable(minutes, 60), hour: nextLookupTable(hours, 24), day: nextLookupTable(days, 32), @@ -233,7 +243,8 @@ export const isParseError = (u: unknown): u is ParseError => hasProperty(u, Pars * import { Cron, Either } from "effect" * * // At 04:00 on every day-of-month from 8 through 14. - * assert.deepStrictEqual(Cron.parse("0 4 8-14 * *"), Either.right(Cron.make({ + * assert.deepStrictEqual(Cron.parse("0 0 4 8-14 * *"), Either.right(Cron.make({ + * seconds: [0], * minutes: [0], * hours: [4], * days: [8, 9, 10, 11, 12, 13, 14], @@ -247,12 +258,17 @@ export const isParseError = (u: unknown): u is ParseError => hasProperty(u, Pars */ export const parse = (cron: string, tz?: DateTime.TimeZone): Either.Either => { const segments = cron.split(" ").filter(String.isNonEmpty) - if (segments.length !== 5) { + if (segments.length !== 5 && segments.length !== 6) { return Either.left(ParseError(`Invalid number of segments in cron expression`, cron)) } - const [minutes, hours, days, months, weekdays] = segments + if (segments.length === 5) { + segments.unshift("0") + } + + const [seconds, minutes, hours, days, months, weekdays] = segments return Either.all({ + seconds: parseSegment(seconds, secondOptions), minutes: parseSegment(minutes, minuteOptions), hours: parseSegment(hours, hourOptions), days: parseSegment(days, dayOptions), @@ -285,6 +301,10 @@ export const match = (cron: Cron, date: DateTime.DateTime.Input): boolean => { timeZone: Option.getOrUndefined(cron.tz) }).pipe(dateTime.toParts) + if (cron.seconds.size !== 0 && !cron.seconds.has(parts.seconds)) { + return false + } + if (cron.minutes.size !== 0 && !cron.minutes.has(parts.minutes)) { return false } @@ -358,19 +378,34 @@ export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => { } const result = dateTime.mutate(zoned, (current) => { - current.setUTCMinutes(current.getUTCMinutes() + 1, 0, 0) + current.setUTCSeconds(current.getUTCSeconds() + 1, 0) for (let i = 0; i < 10_000; i++) { + if (cron.seconds.size !== 0) { + const currentSecond = current.getUTCSeconds() + const nextSecond = cron.next.second[currentSecond] + if (nextSecond === undefined) { + current.setUTCMinutes(current.getUTCMinutes() + 1, cron.first.second) + adjustDst(current) + continue + } + if (nextSecond > currentSecond) { + current.setUTCSeconds(nextSecond) + adjustDst(current) + continue + } + } + if (cron.minutes.size !== 0) { const currentMinute = current.getUTCMinutes() const nextMinute = cron.next.minute[currentMinute] if (nextMinute === undefined) { - current.setUTCHours(current.getUTCHours() + 1, cron.first.minute) + current.setUTCHours(current.getUTCHours() + 1, cron.first.minute, cron.first.second) adjustDst(current) continue } if (nextMinute > currentMinute) { - current.setUTCMinutes(nextMinute) + current.setUTCMinutes(nextMinute, cron.first.second) adjustDst(current) continue } @@ -381,12 +416,12 @@ export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => { const nextHour = cron.next.hour[currentHour] if (nextHour === undefined) { current.setUTCDate(current.getUTCDate() + 1) - current.setUTCHours(cron.first.hour, cron.first.minute) + current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second) adjustDst(current) continue } if (nextHour > currentHour) { - current.setUTCHours(nextHour, cron.first.minute) + current.setUTCHours(nextHour, cron.first.minute, cron.first.second) adjustDst(current) continue } @@ -411,7 +446,7 @@ export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => { const addDays = Math.min(a, b) if (addDays !== 0) { current.setUTCDate(current.getUTCDate() + addDays) - current.setUTCHours(cron.first.hour, cron.first.minute) + current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second) adjustDst(current) continue } @@ -423,13 +458,13 @@ export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => { if (nextMonth === undefined) { current.setUTCFullYear(current.getUTCFullYear() + 1) current.setUTCMonth(cron.first.month, cron.first.day) - current.setUTCHours(cron.first.hour, cron.first.minute) + current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second) adjustDst(current) continue } if (nextMonth > currentMonth) { current.setUTCMonth(nextMonth - 1, cron.first.day) - current.setUTCHours(cron.first.hour, cron.first.minute) + current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second) adjustDst(current) continue } @@ -463,6 +498,7 @@ export const sequence = function*(cron: Cron, now?: DateTime.DateTime.Input): It * @since 2.0.0 */ export const Equivalence: equivalence.Equivalence = equivalence.make((self, that) => + restrictionsEquals(self.seconds, that.seconds) && restrictionsEquals(self.minutes, that.minutes) && restrictionsEquals(self.hours, that.hours) && restrictionsEquals(self.days, that.days) && @@ -486,32 +522,32 @@ export const equals: { } = dual(2, (self: Cron, that: Cron): boolean => Equivalence(self, that)) interface SegmentOptions { - segment: string min: number max: number aliases?: Record | undefined } +const secondOptions: SegmentOptions = { + min: 0, + max: 59 +} + const minuteOptions: SegmentOptions = { - segment: "minute", min: 0, max: 59 } const hourOptions: SegmentOptions = { - segment: "hour", min: 0, max: 23 } const dayOptions: SegmentOptions = { - segment: "day", min: 1, max: 31 } const monthOptions: SegmentOptions = { - segment: "month", min: 1, max: 12, aliases: { @@ -531,7 +567,6 @@ const monthOptions: SegmentOptions = { } const weekdayOptions: SegmentOptions = { - segment: "weekday", min: 0, max: 6, aliases: { diff --git a/packages/effect/src/Schedule.ts b/packages/effect/src/Schedule.ts index 2272fc4b711..2edc7e36f7c 100644 --- a/packages/effect/src/Schedule.ts +++ b/packages/effect/src/Schedule.ts @@ -394,9 +394,9 @@ export const mapInputEffect: { export const count: Schedule = internal.count /** - * Cron schedule that recurs every `minute` that matches the schedule. + * Cron schedule that recurs every interval that matches the schedule. * - * It triggers at zero second of the minute. Producing the timestamps of the cron window. + * It triggers at the beginning of each cron interval, producing the timestamps of the cron window. * * NOTE: `expression` parameter is validated lazily. Must be a valid cron expression. * diff --git a/packages/effect/src/internal/schedule.ts b/packages/effect/src/internal/schedule.ts index 97742c98af7..c5704c5d1f2 100644 --- a/packages/effect/src/internal/schedule.ts +++ b/packages/effect/src/internal/schedule.ts @@ -439,8 +439,8 @@ export const cron = (expression: string | Cron.Cron): Schedule.Schedule<[number, } next = Cron.next(cron, date).getTime() - const start = beginningOfMinute(next) - const end = endOfMinute(next) + const start = beginningOfSecond(next) + const end = endOfSecond(next) return core.succeed([ [false, [next, start, end]], [start, end], diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index 09fb83e3d5c..dab58822351 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -75,6 +75,10 @@ describe("Cron", () => { assertFalse(match("5 4 * * SUN", "2024-01-08 04:05:00")) assertFalse(match("5 4 * * SUN", "2025-01-07 04:05:00")) + assertTrue(match("42 5 0 * 8 *", "2024-08-01 00:05:42")) + assertFalse(match("42 5 0 * 8 *", "2024-09-01 00:05:42")) + assertFalse(match("42 5 0 * 8 *", "2024-08-01 01:05:42")) + const london = DateTime.zoneUnsafeMakeNamed("Europe/London") const londonTime = DateTime.unsafeMakeZoned("2024-06-01 14:15:00Z", { timeZone: london, @@ -98,6 +102,7 @@ describe("Cron", () => { deepStrictEqual(next("23 0-20/2 * * 0", after), new Date("2024-01-07 00:23:00")) deepStrictEqual(next("5 4 * * SUN", after), new Date("2024-01-07 04:05:00")) deepStrictEqual(next("5 4 * DEC SUN", after), new Date("2024-12-01 04:05:00")) + deepStrictEqual(next("30 5 0 8 2 *", after), new Date("2024-02-08 00:05:30")) const london = DateTime.zoneUnsafeMakeNamed("Europe/London") const londonTime = DateTime.unsafeMakeZoned("2024-02-08 00:05:00Z", { diff --git a/packages/effect/test/Schedule.test.ts b/packages/effect/test/Schedule.test.ts index c724aeb180a..958fb43e946 100644 --- a/packages/effect/test/Schedule.test.ts +++ b/packages/effect/test/Schedule.test.ts @@ -594,7 +594,6 @@ describe("Schedule", () => { ] assert.deepStrictEqual(result, expected) })) - it.effect("recur at time matching cron expression", () => Effect.gen(function*($) { const ref = yield* $(Ref.make>([])) @@ -620,6 +619,34 @@ describe("Schedule", () => { ] assert.deepStrictEqual(result, expected) })) + it.effect("recur at time matching cron expression (second granularity)", () => + Effect.gen(function*($) { + const ref = yield* $(Ref.make>([])) + yield* $(TestClock.setTime(new Date(2024, 0, 1, 0, 0, 0).getTime())) + const schedule = Schedule.cron("*/3 * * * * *") + yield* $( + TestClock.currentTimeMillis, + Effect.tap((instant) => Ref.update(ref, Array.append(format(instant)))), + Effect.repeat(schedule), + Effect.fork + ) + yield* $(TestClock.adjust("30 seconds")) + const result = yield* $(Ref.get(ref)) + const expected = [ + "Mon Jan 01 2024 00:00:00", + "Mon Jan 01 2024 00:00:03", + "Mon Jan 01 2024 00:00:06", + "Mon Jan 01 2024 00:00:09", + "Mon Jan 01 2024 00:00:12", + "Mon Jan 01 2024 00:00:15", + "Mon Jan 01 2024 00:00:18", + "Mon Jan 01 2024 00:00:21", + "Mon Jan 01 2024 00:00:24", + "Mon Jan 01 2024 00:00:27", + "Mon Jan 01 2024 00:00:30" + ] + assert.deepStrictEqual(result, expected) + })) it.effect("recur at 01 second of each minute", () => Effect.gen(function*($) { const originOffset = new Date(new Date(new Date().setMinutes(0)).setSeconds(0)).setMilliseconds(0) From 4cb4c925fe653e420d1280e99239246559bf515e Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 10 Dec 2024 22:09:57 +0100 Subject: [PATCH 06/15] added `Cron.unsafeParse` and allow passing the `tz` parameter as `string` (#4106) --- .changeset/polite-trainers-give.md | 5 ++++ packages/effect/src/Cron.ts | 35 ++++++++++++++++++++++-- packages/effect/src/Schedule.ts | 6 +++- packages/effect/src/internal/schedule.ts | 8 ++++-- packages/effect/test/Cron.test.ts | 18 +++++++----- 5 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 .changeset/polite-trainers-give.md diff --git a/.changeset/polite-trainers-give.md b/.changeset/polite-trainers-give.md new file mode 100644 index 00000000000..547ef0f1dd1 --- /dev/null +++ b/.changeset/polite-trainers-give.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Added `Cron.unsafeParse` and allow passing the `Cron.parse` time zone parameter as `string`. diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 1e07f61ddc5..153c50d0f66 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -256,7 +256,7 @@ export const isParseError = (u: unknown): u is ParseError => hasProperty(u, Pars * @since 2.0.0 * @category constructors */ -export const parse = (cron: string, tz?: DateTime.TimeZone): Either.Either => { +export const parse = (cron: string, tz?: DateTime.TimeZone | string): Either.Either => { const segments = cron.split(" ").filter(String.isNonEmpty) if (segments.length !== 5 && segments.length !== 6) { return Either.left(ParseError(`Invalid number of segments in cron expression`, cron)) @@ -267,16 +267,47 @@ export const parse = (cron: string, tz?: DateTime.TimeZone): Either.Either ParseError(`Invalid time zone in cron expression`, tz)) + return Either.all({ + tz: zone, seconds: parseSegment(seconds, secondOptions), minutes: parseSegment(minutes, minuteOptions), hours: parseSegment(hours, hourOptions), days: parseSegment(days, dayOptions), months: parseSegment(months, monthOptions), weekdays: parseSegment(weekdays, weekdayOptions) - }).pipe(Either.map((segments) => make({ ...segments, tz }))) + }).pipe(Either.map(make)) } +/** + * Parses a cron expression into a `Cron` instance. + * + * Throws on failure. + * + * @param cron - The cron expression to parse. + * + * @example + * ```ts + * import { Cron } from "effect" + * + * // At 04:00 on every day-of-month from 8 through 14. + * assert.deepStrictEqual(Cron.unsafeParse("0 4 8-14 * *"), Cron.make({ + * minutes: [0], + * hours: [4], + * days: [8, 9, 10, 11, 12, 13, 14], + * months: [], + * weekdays: [] + * })) + * ``` + * + * @since 2.0.0 + * @category constructors + */ +export const unsafeParse = (cron: string, tz?: DateTime.TimeZone | string): Cron => Either.getOrThrow(parse(cron, tz)) + /** * Checks if a given `Date` falls within an active `Cron` time window. * diff --git a/packages/effect/src/Schedule.ts b/packages/effect/src/Schedule.ts index 2edc7e36f7c..94b1c2a45aa 100644 --- a/packages/effect/src/Schedule.ts +++ b/packages/effect/src/Schedule.ts @@ -5,6 +5,7 @@ import type * as Cause from "./Cause.js" import type * as Chunk from "./Chunk.js" import type * as Context from "./Context.js" import type * as Cron from "./Cron.js" +import type * as DateTime from "./DateTime.js" import type * as Duration from "./Duration.js" import type * as Effect from "./Effect.js" import type * as Either from "./Either.js" @@ -403,7 +404,10 @@ export const count: Schedule = internal.count * @since 2.0.0 * @category constructors */ -export const cron: (expression: string | Cron.Cron) => Schedule<[number, number]> = internal.cron +export const cron: { + (cron: Cron.Cron): Schedule<[number, number]> + (expression: string, tz?: DateTime.TimeZone | string): Schedule<[number, number]> +} = internal.cron /** * Cron-like schedule that recurs every specified `day` of month. Won't recur diff --git a/packages/effect/src/internal/schedule.ts b/packages/effect/src/internal/schedule.ts index c5704c5d1f2..a10da36df9a 100644 --- a/packages/effect/src/internal/schedule.ts +++ b/packages/effect/src/internal/schedule.ts @@ -3,6 +3,7 @@ import * as Chunk from "../Chunk.js" import * as Clock from "../Clock.js" import * as Context from "../Context.js" import * as Cron from "../Cron.js" +import type * as DateTime from "../DateTime.js" import * as Duration from "../Duration.js" import type * as Effect from "../Effect.js" import * as Either from "../Either.js" @@ -413,8 +414,11 @@ export const mapInputEffect = dual< ))) /** @internal */ -export const cron = (expression: string | Cron.Cron): Schedule.Schedule<[number, number]> => { - const parsed = Cron.isCron(expression) ? Either.right(expression) : Cron.parse(expression) +export const cron: { + (expression: Cron.Cron): Schedule.Schedule<[number, number]> + (expression: string, tz?: DateTime.TimeZone | string): Schedule.Schedule<[number, number]> +} = (expression: string | Cron.Cron, tz?: DateTime.TimeZone | string): Schedule.Schedule<[number, number]> => { + const parsed = Cron.isCron(expression) ? Either.right(expression) : Cron.parse(expression, tz) return makeWithState<[boolean, [number, number, number]], unknown, [number, number]>( [true, [Number.MIN_SAFE_INTEGER, 0, 0]], (now, _, [initial, previous]) => { diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index dab58822351..5b11a28f8df 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -7,7 +7,7 @@ import * as Option from "effect/Option" import { assertFalse, assertTrue, deepStrictEqual } from "effect/test/util" import { describe, it } from "vitest" -const parse = (input: string, tz?: DateTime.TimeZone) => Either.getOrThrowWith(Cron.parse(input, tz), identity) +const parse = (input: string, tz?: DateTime.TimeZone | string) => Either.getOrThrowWith(Cron.parse(input, tz), identity) const match = (input: Cron.Cron | string, date: DateTime.DateTime.Input) => Cron.match(Cron.isCron(input) ? input : parse(input), date) const next = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) => @@ -151,10 +151,12 @@ describe("Cron", () => { }) it("handles transition into daylight savings time", () => { - const berlin = DateTime.zoneUnsafeMakeNamed("Europe/Berlin") const make = (date: string) => DateTime.makeZonedFromString(date).pipe(Option.getOrThrow) - const sequence = Cron.sequence(parse("30 * * * *", berlin), make("2024-03-31T00:00:00.000+01:00[Europe/Berlin]")) - const next = (): DateTime.Zoned => DateTime.unsafeMakeZoned(sequence.next().value, { timeZone: berlin }) + const sequence = Cron.sequence( + parse("30 * * * *", "Europe/Berlin"), + make("2024-03-31T00:00:00.000+01:00[Europe/Berlin]") + ) + const next = (): DateTime.Zoned => DateTime.unsafeMakeZoned(sequence.next().value, { timeZone: "Europe/Berlin" }) const a = make("2024-03-31T00:30:00.000+01:00[Europe/Berlin]") const b = make("2024-03-31T01:30:00.000+01:00[Europe/Berlin]") @@ -168,10 +170,12 @@ describe("Cron", () => { }) it("handles transition out of daylight savings time", () => { - const berlin = DateTime.zoneUnsafeMakeNamed("Europe/Berlin") const make = (date: string) => DateTime.makeZonedFromString(date).pipe(Option.getOrThrow) - const sequence = Cron.sequence(parse("30 * * * *", berlin), make("2024-10-27T00:00:00.000+02:00[Europe/Berlin]")) - const next = (): DateTime.Zoned => DateTime.unsafeMakeZoned(sequence.next().value, { timeZone: berlin }) + const sequence = Cron.sequence( + parse("30 * * * *", "Europe/Berlin"), + make("2024-10-27T00:00:00.000+02:00[Europe/Berlin]") + ) + const next = (): DateTime.Zoned => DateTime.unsafeMakeZoned(sequence.next().value, { timeZone: "Europe/Berlin" }) const a = make("2024-10-27T00:30:00.000+02:00[Europe/Berlin]") // const x = make("2024-10-27T01:30:00.000+02:00[Europe/Berlin]") // TODO: Our implementation skips this. From d110114eaaa2a67b4e19728aee47e3f586e86234 Mon Sep 17 00:00:00 2001 From: Titouan CREACH Date: Thu, 12 Dec 2024 12:24:29 +0100 Subject: [PATCH 07/15] add UriComponent schemas and encoding (#3982) Co-authored-by: Sebastian Lorenz --- .changeset/brave-apes-accept.md | 5 ++ packages/effect/src/Encoding.ts | 65 +++++++++++++++++++ packages/effect/src/Schema.ts | 42 ++++++++++++ .../effect/src/internal/encoding/common.ts | 21 ++++++ packages/effect/test/Encoding.test.ts | 44 +++++++++++++ .../String/StringFromUriComponent.test.ts | 32 +++++++++ 6 files changed, 209 insertions(+) create mode 100644 .changeset/brave-apes-accept.md create mode 100644 packages/effect/test/Schema/Schema/String/StringFromUriComponent.test.ts diff --git a/.changeset/brave-apes-accept.md b/.changeset/brave-apes-accept.md new file mode 100644 index 00000000000..92c2bc67af6 --- /dev/null +++ b/.changeset/brave-apes-accept.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Added encodeUriComponent/decodeUriComponent for both Encoding and Schema diff --git a/packages/effect/src/Encoding.ts b/packages/effect/src/Encoding.ts index e0e8b61947f..9c2f4cbe03c 100644 --- a/packages/effect/src/Encoding.ts +++ b/packages/effect/src/Encoding.ts @@ -88,6 +88,30 @@ export const decodeHex = (str: string): Either.Either Either.map(decodeHex(str), (_) => Common.decoder.decode(_)) +/** + * Encodes a UTF-8 `string` into a URI component `string`. + * + * @category encoding + * @since 3.12.0 + */ +export const encodeUriComponent = (str: string): Either.Either => + Either.try({ + try: () => encodeURIComponent(str), + catch: (e) => EncodeException(str, e instanceof Error ? e.message : "Invalid input") + }) + +/** + * Decodes a URI component `string` into a UTF-8 `string`. + * + * @category decoding + * @since 3.12.0 + */ +export const decodeUriComponent = (str: string): Either.Either => + Either.try({ + try: () => decodeURIComponent(str), + catch: (e) => DecodeException(str, e instanceof Error ? e.message : "Invalid input") + }) + /** * @since 2.0.0 * @category symbols @@ -128,3 +152,44 @@ export const DecodeException: (input: string, message?: string) => DecodeExcepti * @category refinements */ export const isDecodeException: (u: unknown) => u is DecodeException = Common.isDecodeException + +/** + * @since 3.12.0 + * @category symbols + */ +export const EncodeExceptionTypeId: unique symbol = Common.EncodeExceptionTypeId + +/** + * @since 3.12.0 + * @category symbols + */ +export type EncodeExceptionTypeId = typeof EncodeExceptionTypeId + +/** + * Represents a checked exception which occurs when encoding fails. + * + * @since 3.12.0 + * @category models + */ +export interface EncodeException { + readonly _tag: "EncodeException" + readonly [EncodeExceptionTypeId]: EncodeExceptionTypeId + readonly input: string + readonly message?: string +} + +/** + * Creates a checked exception which occurs when encoding fails. + * + * @since 3.12.0 + * @category errors + */ +export const EncodeException: (input: string, message?: string) => EncodeException = Common.EncodeException + +/** + * Returns `true` if the specified value is an `Exception`, `false` otherwise. + * + * @since 3.12.0 + * @category refinements + */ +export const isEncodeException: (u: unknown) => u is EncodeException = Common.isEncodeException diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 395a36c9127..e5548a2d8e1 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -5932,6 +5932,48 @@ export const StringFromHex: Schema = makeEncodingTransformation( Encoding.encodeHex ) +/** + * Decodes a URI component encoded string into a UTF-8 string. + * Can be used to store data in a URL. + * + * @example + * ```ts + * import { Schema } from "effect" + * + * const PaginationSchema = Schema.Struct({ + * maxItemPerPage: Schema.Number, + * page: Schema.Number + * }) + * + * const UrlSchema = Schema.compose(Schema.StringFromUriComponent, Schema.parseJson(PaginationSchema)) + * + * console.log(Schema.encodeSync(UrlSchema)({ maxItemPerPage: 10, page: 1 })) + * // Output: %7B%22maxItemPerPage%22%3A10%2C%22page%22%3A1%7D + * ``` + * + * @category string transformations + * @since 3.12.0 + */ +export const StringFromUriComponent = transformOrFail( + String$.annotations({ + description: `A string that is interpreted as being UriComponent-encoded and will be decoded into a UTF-8 string` + }), + String$, + { + strict: true, + decode: (s, _, ast) => + either_.mapLeft( + Encoding.decodeUriComponent(s), + (decodeException) => new ParseResult.Type(ast, s, decodeException.message) + ), + encode: (u, _, ast) => + either_.mapLeft( + Encoding.encodeUriComponent(u), + (encodeException) => new ParseResult.Type(ast, u, encodeException.message) + ) + } +).annotations({ identifier: `StringFromUriComponent` }) + /** * @category schema id * @since 3.10.0 diff --git a/packages/effect/src/internal/encoding/common.ts b/packages/effect/src/internal/encoding/common.ts index 60244c27b5a..580cd6ffae0 100644 --- a/packages/effect/src/internal/encoding/common.ts +++ b/packages/effect/src/internal/encoding/common.ts @@ -23,6 +23,27 @@ export const DecodeException = (input: string, message?: string): Encoding.Decod /** @internal */ export const isDecodeException = (u: unknown): u is Encoding.DecodeException => hasProperty(u, DecodeExceptionTypeId) +/** @internal */ +export const EncodeExceptionTypeId: Encoding.EncodeExceptionTypeId = Symbol.for( + "effect/Encoding/errors/Encode" +) as Encoding.EncodeExceptionTypeId + +/** @internal */ +export const EncodeException = (input: string, message?: string): Encoding.EncodeException => { + const out: Mutable = { + _tag: "EncodeException", + [EncodeExceptionTypeId]: EncodeExceptionTypeId, + input + } + if (isString(message)) { + out.message = message + } + return out +} + +/** @internal */ +export const isEncodeException = (u: unknown): u is Encoding.EncodeException => hasProperty(u, EncodeExceptionTypeId) + /** @interal */ export const encoder = new TextEncoder() diff --git a/packages/effect/test/Encoding.test.ts b/packages/effect/test/Encoding.test.ts index e0bd0107756..8c477c8d6f4 100644 --- a/packages/effect/test/Encoding.test.ts +++ b/packages/effect/test/Encoding.test.ts @@ -161,3 +161,47 @@ describe("Hex", () => { assert(Encoding.isDecodeException(result.left)) }) }) + +describe("UriComponent", () => { + const valid: Array<[uri: string, raw: string]> = [ + ["", ""], + ["hello", "hello"], + ["hello%20world", "hello world"], + ["hello%20world%2F", "hello world/"], + ["%20", " "], + ["%2F", "/"] + ] + + const invalidDecode: Array = [ + "hello%2world" + ] + + const invalidEncode: Array = [ + "\uD800", + "\uDFFF" + ] + + it.each(valid)(`should decode %j => %j`, (uri: string, raw: string) => { + const decoded = Encoding.decodeUriComponent(uri) + assert(Either.isRight(decoded)) + deepStrictEqual(decoded.right, raw) + }) + + it.each(valid)(`should encode %j => %j`, (uri: string, raw: string) => { + const encoded = Encoding.encodeUriComponent(raw) + assert(Either.isRight(encoded)) + deepStrictEqual(encoded.right, uri) + }) + + it.each(invalidDecode)(`should refuse to decode %j`, (uri: string) => { + const result = Encoding.decodeUriComponent(uri) + assert(Either.isLeft(result)) + assert(Encoding.isDecodeException(result.left)) + }) + + it.each(invalidEncode)(`should refuse to encode %j`, (raw: string) => { + const result = Encoding.encodeUriComponent(raw) + assert(Either.isLeft(result)) + assert(Encoding.isEncodeException(result.left)) + }) +}) diff --git a/packages/effect/test/Schema/Schema/String/StringFromUriComponent.test.ts b/packages/effect/test/Schema/Schema/String/StringFromUriComponent.test.ts new file mode 100644 index 00000000000..5b5bd2d9d94 --- /dev/null +++ b/packages/effect/test/Schema/Schema/String/StringFromUriComponent.test.ts @@ -0,0 +1,32 @@ +import * as S from "effect/Schema" +import * as Util from "effect/test/Schema/TestUtils" +import { describe, it } from "vitest" + +describe("StringFromUriComponent", () => { + const schema = S.StringFromUriComponent + + it("encoding", async () => { + await Util.expectEncodeSuccess(schema, "шеллы", "%D1%88%D0%B5%D0%BB%D0%BB%D1%8B") + await Util.expectEncodeFailure( + schema, + "Hello\uD800", + `StringFromUriComponent +└─ Transformation process failure + └─ URI malformed` + ) + }) + + it("decoding", async () => { + await Util.expectDecodeUnknownSuccess(schema, "%D1%88%D0%B5%D0%BB%D0%BB%D1%8B", "шеллы") + await Util.expectDecodeUnknownSuccess(schema, "hello", "hello") + await Util.expectDecodeUnknownSuccess(schema, "hello%20world", "hello world") + + await Util.expectDecodeUnknownFailure( + schema, + "Hello%2world", + `StringFromUriComponent +└─ Transformation process failure + └─ URI malformed` + ) + }) +}) From 7a1d1955967d67ab616ef5ae4d608aff33e73031 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 13 Dec 2024 12:42:34 +1300 Subject: [PATCH 08/15] add span annotation to disable propagation to the tracer (#4123) --- .changeset/shiny-vans-sleep.md | 5 ++ packages/effect/src/Tracer.ts | 14 +++ packages/effect/src/internal/core-effect.ts | 96 ++++++++++++--------- packages/effect/src/internal/core.ts | 15 ++-- packages/effect/src/internal/tracer.ts | 6 ++ packages/effect/test/Tracer.test.ts | 41 ++++++++- 6 files changed, 126 insertions(+), 51 deletions(-) create mode 100644 .changeset/shiny-vans-sleep.md diff --git a/.changeset/shiny-vans-sleep.md b/.changeset/shiny-vans-sleep.md new file mode 100644 index 00000000000..438b0b976b3 --- /dev/null +++ b/.changeset/shiny-vans-sleep.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add span annotation to disable propagation to the tracer diff --git a/packages/effect/src/Tracer.ts b/packages/effect/src/Tracer.ts index 038ddff03f9..e00d5a86837 100644 --- a/packages/effect/src/Tracer.ts +++ b/packages/effect/src/Tracer.ts @@ -164,3 +164,17 @@ export const externalSpan: ( */ export const tracerWith: (f: (tracer: Tracer) => Effect.Effect) => Effect.Effect = defaultServices.tracerWith + +/** + * @since 3.12.0 + * @category annotations + */ +export interface DisablePropagation { + readonly _: unique symbol +} + +/** + * @since 3.12.0 + * @category annotations + */ +export const DisablePropagation: Context.Reference = internal.DisablePropagation diff --git a/packages/effect/src/internal/core-effect.ts b/packages/effect/src/internal/core-effect.ts index 8ea0fac277a..1d0fddaa808 100644 --- a/packages/effect/src/internal/core-effect.ts +++ b/packages/effect/src/internal/core-effect.ts @@ -2020,61 +2020,75 @@ export const linkSpans = dual< const bigint0 = BigInt(0) +const filterDisablePropagation: (self: Option.Option) => Option.Option = Option.flatMap( + (span) => + Context.get(span.context, internalTracer.DisablePropagation) + ? span._tag === "Span" ? filterDisablePropagation(span.parent) : Option.none() + : Option.some(span) +) + /** @internal */ export const unsafeMakeSpan = ( fiber: FiberRuntime, name: string, options: Tracer.SpanOptions ) => { - const enabled = fiber.getFiberRef(core.currentTracerEnabled) - if (enabled === false) { - return core.noopSpan(name) - } - + const disablePropagation = !fiber.getFiberRef(core.currentTracerEnabled) || + (options.context && Context.get(options.context, internalTracer.DisablePropagation)) const context = fiber.getFiberRef(core.currentContext) - const services = fiber.getFiberRef(defaultServices.currentServices) - - const tracer = Context.get(services, internalTracer.tracerTag) - const clock = Context.get(services, Clock.Clock) - const timingEnabled = fiber.getFiberRef(core.currentTracerTimingEnabled) - - const fiberRefs = fiber.getFiberRefs() - const annotationsFromEnv = FiberRefs.get(fiberRefs, core.currentTracerSpanAnnotations) - const linksFromEnv = FiberRefs.get(fiberRefs, core.currentTracerSpanLinks) - const parent = options.parent ? Option.some(options.parent) : options.root ? Option.none() - : Context.getOption(context, internalTracer.spanTag) - - const links = linksFromEnv._tag === "Some" ? - options.links !== undefined ? - [ - ...Chunk.toReadonlyArray(linksFromEnv.value), - ...(options.links ?? []) - ] : - Chunk.toReadonlyArray(linksFromEnv.value) : - options.links ?? Arr.empty() - - const span = tracer.span( - name, - parent, - options.context ?? Context.empty(), - links, - timingEnabled ? clock.unsafeCurrentTimeNanos() : bigint0, - options.kind ?? "internal" - ) + : filterDisablePropagation(Context.getOption(context, internalTracer.spanTag)) - if (typeof options.captureStackTrace === "function") { - internalCause.spanToTrace.set(span, options.captureStackTrace) - } + let span: Tracer.Span - if (annotationsFromEnv._tag === "Some") { - HashMap.forEach(annotationsFromEnv.value, (value, key) => span.attribute(key, value)) + if (disablePropagation) { + span = core.noopSpan({ + name, + parent, + context: Context.add(options.context ?? Context.empty(), internalTracer.DisablePropagation, true) + }) + } else { + const services = fiber.getFiberRef(defaultServices.currentServices) + + const tracer = Context.get(services, internalTracer.tracerTag) + const clock = Context.get(services, Clock.Clock) + const timingEnabled = fiber.getFiberRef(core.currentTracerTimingEnabled) + + const fiberRefs = fiber.getFiberRefs() + const annotationsFromEnv = FiberRefs.get(fiberRefs, core.currentTracerSpanAnnotations) + const linksFromEnv = FiberRefs.get(fiberRefs, core.currentTracerSpanLinks) + + const links = linksFromEnv._tag === "Some" ? + options.links !== undefined ? + [ + ...Chunk.toReadonlyArray(linksFromEnv.value), + ...(options.links ?? []) + ] : + Chunk.toReadonlyArray(linksFromEnv.value) : + options.links ?? Arr.empty() + + span = tracer.span( + name, + parent, + options.context ?? Context.empty(), + links, + timingEnabled ? clock.unsafeCurrentTimeNanos() : bigint0, + options.kind ?? "internal" + ) + + if (annotationsFromEnv._tag === "Some") { + HashMap.forEach(annotationsFromEnv.value, (value, key) => span.attribute(key, value)) + } + if (options.attributes !== undefined) { + Object.entries(options.attributes).forEach(([k, v]) => span.attribute(k, v)) + } } - if (options.attributes !== undefined) { - Object.entries(options.attributes).forEach(([k, v]) => span.attribute(k, v)) + + if (typeof options.captureStackTrace === "function") { + internalCause.spanToTrace.set(span, options.captureStackTrace) } return span diff --git a/packages/effect/src/internal/core.ts b/packages/effect/src/internal/core.ts index e30e50c7ce4..2254c4ef588 100644 --- a/packages/effect/src/internal/core.ts +++ b/packages/effect/src/internal/core.ts @@ -3063,14 +3063,11 @@ export const currentSpanFromFiber = (fiber: Fiber.RuntimeFiber): Opt return span !== undefined && span._tag === "Span" ? Option.some(span) : Option.none() } -const NoopSpanProto: Tracer.Span = { +const NoopSpanProto: Omit = { _tag: "Span", spanId: "noop", traceId: "noop", - name: "noop", sampled: false, - parent: Option.none(), - context: Context.empty(), status: { _tag: "Ended", startTime: BigInt(0), @@ -3086,8 +3083,8 @@ const NoopSpanProto: Tracer.Span = { } /** @internal */ -export const noopSpan = (name: string): Tracer.Span => { - const span = Object.create(NoopSpanProto) - span.name = name - return span -} +export const noopSpan = (options: { + readonly name: string + readonly parent: Option.Option + readonly context: Context.Context +}): Tracer.Span => Object.assign(Object.create(NoopSpanProto), options) diff --git a/packages/effect/src/internal/tracer.ts b/packages/effect/src/internal/tracer.ts index 6370e6ce96a..fd327826ce1 100644 --- a/packages/effect/src/internal/tracer.ts +++ b/packages/effect/src/internal/tracer.ts @@ -3,6 +3,7 @@ */ import * as Context from "../Context.js" import type * as Exit from "../Exit.js" +import { constFalse } from "../Function.js" import type * as Option from "../Option.js" import type * as Tracer from "../Tracer.js" @@ -135,3 +136,8 @@ export const addSpanStackTrace = (options: Tracer.SpanOptions | undefined): Trac } } } + +/** @internal */ +export const DisablePropagation = Context.Reference()("effect/Tracer/DisablePropagation", { + defaultValue: constFalse +}) diff --git a/packages/effect/test/Tracer.test.ts b/packages/effect/test/Tracer.test.ts index d41574e5eb6..3d99d47eb5b 100644 --- a/packages/effect/test/Tracer.test.ts +++ b/packages/effect/test/Tracer.test.ts @@ -1,3 +1,4 @@ +import { Cause, Tracer } from "effect" import * as Context from "effect/Context" import { millis, seconds } from "effect/Duration" import * as Effect from "effect/Effect" @@ -274,6 +275,44 @@ it.effect("withTracerEnabled", () => assert.deepEqual(spanB.name, "B") })) +describe("Tracer.DisablePropagation", () => { + it.effect("creates noop span", () => + Effect.gen(function*() { + const span = yield* Effect.currentSpan.pipe( + Effect.withSpan("A", { context: Tracer.DisablePropagation.context(true) }) + ) + const spanB = yield* Effect.currentSpan.pipe( + Effect.withSpan("B") + ) + + assert.deepEqual(span.name, "A") + assert.deepEqual(span.spanId, "noop") + assert.deepEqual(spanB.name, "B") + })) + + it.effect("captures stack", () => + Effect.gen(function*() { + const cause = yield* Effect.die(new Error("boom")).pipe( + Effect.withSpan("C", { context: Tracer.DisablePropagation.context(true) }), + Effect.sandbox, + Effect.flip + ) + assert.include(Cause.pretty(cause), "Tracer.test.ts:295") + })) + + it.effect("isnt used as parent span", () => + Effect.gen(function*() { + const span = yield* Effect.currentSpan.pipe( + Effect.withSpan("child"), + Effect.withSpan("disabled", { context: Tracer.DisablePropagation.context(true) }), + Effect.withSpan("parent") + ) + assert.strictEqual(span.name, "child") + assert(span.parent._tag === "Some" && span.parent.value._tag === "Span") + assert.strictEqual(span.parent.value.name, "parent") + })) +}) + it.effect("includes trace when errored", () => Effect.gen(function*() { let maybeSpan: undefined | Span @@ -290,7 +329,7 @@ it.effect("includes trace when errored", () => }) yield* Effect.flip(getSpan("fail")) assert.isDefined(maybeSpan) - assert.include(maybeSpan!.attributes.get("code.stacktrace"), "Tracer.test.ts:291:24") + assert.include(maybeSpan!.attributes.get("code.stacktrace"), "Tracer.test.ts:330:24") })) describe("functionWithSpan", () => { From 5fbbec97444d5f37cc9fc410b99cf25724c2b70f Mon Sep 17 00:00:00 2001 From: Dapper Mink Date: Tue, 17 Dec 2024 06:15:53 +0900 Subject: [PATCH 09/15] Add Context.mergeAll (#4135) --- .changeset/rare-eagles-jog.md | 5 ++++ packages/effect/src/Context.ts | 30 ++++++++++++++++++++++ packages/effect/src/internal/context.ts | 13 ++++++++++ packages/effect/test/Context.test.ts | 33 +++++++++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 .changeset/rare-eagles-jog.md diff --git a/.changeset/rare-eagles-jog.md b/.changeset/rare-eagles-jog.md new file mode 100644 index 00000000000..94844ba785b --- /dev/null +++ b/.changeset/rare-eagles-jog.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add Context.mergeAll to combine multiple Contexts into one. diff --git a/packages/effect/src/Context.ts b/packages/effect/src/Context.ts index 2373b44abf1..d684b3e8dd5 100644 --- a/packages/effect/src/Context.ts +++ b/packages/effect/src/Context.ts @@ -428,6 +428,36 @@ export const merge: { (self: Context, that: Context): Context } = internal.merge +/** + * Merges any number of `Context`s, returning a new `Context` containing the services of all. + * + * @param ctxs - The `Context`s to merge. + * + * @example + * ```ts + * import { Context } from "effect" + * + * const Port = Context.GenericTag<{ PORT: number }>("Port") + * const Timeout = Context.GenericTag<{ TIMEOUT: number }>("Timeout") + * const Host = Context.GenericTag<{ HOST: string }>("Host") + * + * const firstContext = Context.make(Port, { PORT: 8080 }) + * const secondContext = Context.make(Timeout, { TIMEOUT: 5000 }) + * const thirdContext = Context.make(Host, { HOST: "localhost" }) + * + * const Services = Context.mergeAll(firstContext, secondContext, thirdContext) + * + * assert.deepStrictEqual(Context.get(Services, Port), { PORT: 8080 }) + * assert.deepStrictEqual(Context.get(Services, Timeout), { TIMEOUT: 5000 }) + * assert.deepStrictEqual(Context.get(Services, Host), { HOST: "localhost" }) + * ``` + * + * @since 3.12.0 + */ +export const mergeAll: >( + ...ctxs: [...{ [K in keyof T]: Context }] +) => Context = internal.mergeAll + /** * Returns a new `Context` that contains only the specified services. * diff --git a/packages/effect/src/internal/context.ts b/packages/effect/src/internal/context.ts index 26f10d23263..20e0badd047 100644 --- a/packages/effect/src/internal/context.ts +++ b/packages/effect/src/internal/context.ts @@ -295,6 +295,19 @@ export const merge = dual< return makeContext(map) }) +/** @internal */ +export const mergeAll = >( + ...ctxs: [...{ [K in keyof T]: C.Context }] +): C.Context => { + const map = new Map() + for (const ctx of ctxs) { + for (const [tag, s] of ctx.unsafeMap) { + map.set(tag, s) + } + } + return makeContext(map) +} + /** @internal */ export const pick = >>(...tags: S) => diff --git a/packages/effect/test/Context.test.ts b/packages/effect/test/Context.test.ts index 9475881cd35..4641921879e 100644 --- a/packages/effect/test/Context.test.ts +++ b/packages/effect/test/Context.test.ts @@ -256,6 +256,39 @@ describe("Context", () => { expect(result.pipe(Context.get(A))).toEqual({ a: 0 }) }) + it("mergeAll", () => { + const env = Context.mergeAll( + Context.make(A, { a: 0 }), + Context.make(B, { b: 1 }), + Context.make(C, { c: 2 }) + ) + + const pruned = pipe( + env, + Context.pick(A, B) + ) + + expect(pipe( + pruned, + Context.get(A) + )).toEqual({ a: 0 }) + + expect(pipe( + pruned, + Context.getOption(B) + )).toEqual(O.some({ b: 1 })) + + expect(pipe( + pruned, + Context.getOption(C) + )).toEqual(O.none()) + + expect(pipe( + env, + Context.getOption(C) + )).toEqual(O.some({ c: 2 })) + }) + it("isContext", () => { expect(Context.isContext(Context.empty())).toEqual(true) expect(Context.isContext(null)).toEqual(false) From 236cff4444a2c2ea72926f5039ce685e583ffd1a Mon Sep 17 00:00:00 2001 From: Samuel Briole Date: Wed, 18 Dec 2024 09:27:14 +0100 Subject: [PATCH 10/15] add `DateTimeUtcFromDate` schema (#3956) Co-authored-by: Giulio Canti --- .changeset/gold-squids-run.md | 5 +++ packages/effect/src/Schema.ts | 16 ++++++++ .../DateTime/DateTimeUtcFromDate.test.ts | 39 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 .changeset/gold-squids-run.md create mode 100644 packages/effect/test/Schema/Schema/DateTime/DateTimeUtcFromDate.test.ts diff --git a/.changeset/gold-squids-run.md b/.changeset/gold-squids-run.md new file mode 100644 index 00000000000..93a97209124 --- /dev/null +++ b/.changeset/gold-squids-run.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add DateTimeUtcFromDate schema diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index e5548a2d8e1..6c7bb9b3fd1 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -6429,6 +6429,22 @@ export class DateTimeUtcFromNumber extends transformOrFail( } ).annotations({ identifier: "DateTimeUtcFromNumber" }) {} +/** + * Defines a schema that attempts to convert a `Date` to a `DateTime.Utc` instance using the `DateTime.unsafeMake` constructor. + * + * @category DateTime.Utc transformations + * @since 3.12.0 + */ +export class DateTimeUtcFromDate extends transformOrFail( + DateFromSelf.annotations({ description: "a Date that will be parsed into a DateTime.Utc" }), + DateTimeUtcFromSelf, + { + strict: true, + decode: decodeDateTime, + encode: (dt) => ParseResult.succeed(dateTime.toDateUtc(dt)) + } +).annotations({ identifier: "DateTimeUtcFromDate" }) {} + /** * Defines a schema that attempts to convert a `string` to a `DateTime.Utc` instance using the `DateTime.unsafeMake` constructor. * diff --git a/packages/effect/test/Schema/Schema/DateTime/DateTimeUtcFromDate.test.ts b/packages/effect/test/Schema/Schema/DateTime/DateTimeUtcFromDate.test.ts new file mode 100644 index 00000000000..739f831f280 --- /dev/null +++ b/packages/effect/test/Schema/Schema/DateTime/DateTimeUtcFromDate.test.ts @@ -0,0 +1,39 @@ +import * as DateTime from "effect/DateTime" +import * as S from "effect/Schema" +import * as Util from "effect/test/Schema/TestUtils" +import { describe, expect, it } from "vitest" + +describe("DateTimeUtcFromDate", () => { + it("decoding", async () => { + await Util.expectDecodeUnknownSuccess(S.DateTimeUtcFromDate, new Date(0), DateTime.unsafeMake(0)) + await Util.expectDecodeUnknownSuccess( + S.DateTimeUtcFromDate, + new Date("2024-12-06T00:00:00Z"), + DateTime.unsafeMake({ day: 6, month: 12, year: 2024, hour: 0, minute: 0, second: 0, millisecond: 0 }) + ) + + await Util.expectDecodeUnknownFailure( + S.DateTimeUtcFromDate, + null, + `DateTimeUtcFromDate +└─ Encoded side transformation failure + └─ Expected DateFromSelf, actual null` + ) + await Util.expectDecodeUnknownFailure( + S.DateTimeUtcFromDate, + new Date(NaN), + `DateTimeUtcFromDate +└─ Transformation process failure + └─ Expected DateTimeUtcFromDate, actual Invalid Date` + ) + }) + + it("encoding", async () => { + await Util.expectEncodeSuccess(S.DateTimeUtcFromDate, DateTime.unsafeMake(0), new Date(0)) + expect( + S.encodeSync(S.DateTimeUtcFromDate)( + DateTime.unsafeMake({ day: 6, month: 12, year: 2024, hour: 0, minute: 0, second: 0, millisecond: 0 }) + ) + ).toStrictEqual(new Date("2024-12-06T00:00:00Z")) + }) +}) From a5d1dab2922c852751c5c92eb79491fd5686601b Mon Sep 17 00:00:00 2001 From: Michael Arnaldi Date: Wed, 18 Dec 2024 21:32:20 +0100 Subject: [PATCH 11/15] Carry both call-site and definition site in Effect.fn, auto-trace to anon (#4155) Co-authored-by: Tim --- .changeset/quiet-kings-impress.md | 5 + .changeset/weak-pears-remember.md | 5 + packages/effect/src/Effect.ts | 148 ++++++++++++++++++++------ packages/effect/src/internal/cause.ts | 14 ++- packages/effect/src/internal/core.ts | 18 +++- 5 files changed, 147 insertions(+), 43 deletions(-) create mode 100644 .changeset/quiet-kings-impress.md create mode 100644 .changeset/weak-pears-remember.md diff --git a/.changeset/quiet-kings-impress.md b/.changeset/quiet-kings-impress.md new file mode 100644 index 00000000000..828632292a3 --- /dev/null +++ b/.changeset/quiet-kings-impress.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add Effect.fnUntraced - an untraced version of Effect.fn diff --git a/.changeset/weak-pears-remember.md b/.changeset/weak-pears-remember.md new file mode 100644 index 00000000000..e28a4dd11f2 --- /dev/null +++ b/.changeset/weak-pears-remember.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Carry both call-site and definition site in Effect.fn, auto-trace to anon diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index cee4d82cde2..75d93facf79 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -37,6 +37,7 @@ import * as layer from "./internal/layer.js" import * as query from "./internal/query.js" import * as _runtime from "./internal/runtime.js" import * as _schedule from "./internal/schedule.js" +import * as internalTracer from "./internal/tracer.js" import type * as Layer from "./Layer.js" import type { LogLevel } from "./LogLevel.js" import type * as ManagedRuntime from "./ManagedRuntime.js" @@ -60,7 +61,7 @@ import type * as Supervisor from "./Supervisor.js" import type * as Tracer from "./Tracer.js" import type { Concurrency, Contravariant, Covariant, NoExcessProperties, NoInfer, NotFunction } from "./Types.js" import type * as Unify from "./Unify.js" -import { internalCall, isGeneratorFunction, type YieldWrap } from "./Utils.js" +import { isGeneratorFunction, type YieldWrap } from "./Utils.js" /** * @since 2.0.0 @@ -11576,9 +11577,28 @@ export const fn: name: string, options?: Tracer.SpanOptions ) => fn.Gen & fn.NonGen) = function(nameOrBody: Function | string, ...pipeables: Array) { + const limit = Error.stackTraceLimit + Error.stackTraceLimit = 2 + const errorDef = new Error() + Error.stackTraceLimit = limit if (typeof nameOrBody !== "string") { - return function(this: any) { - return fnApply(this, nameOrBody, arguments as any, pipeables) + return function(this: any, ...args: Array) { + const limit = Error.stackTraceLimit + Error.stackTraceLimit = 2 + const errorCall = new Error() + Error.stackTraceLimit = limit + return fnApply({ + self: this, + body: nameOrBody, + args, + pipeables, + spanName: "", + spanOptions: { + context: internalTracer.DisablePropagation.context(true) + }, + errorDef, + errorCall + }) } as any } const name = nameOrBody @@ -11587,53 +11607,111 @@ export const fn: return function(this: any, ...args: Array) { const limit = Error.stackTraceLimit Error.stackTraceLimit = 2 - const error = new Error() + const errorCall = new Error() Error.stackTraceLimit = limit - let cache: false | string = false - const captureStackTrace = () => { - if (cache !== false) { - return cache - } - if (error.stack) { - const stack = error.stack.trim().split("\n") - cache = stack.slice(2).join("\n").trim() - return cache - } - } - const effect = fnApply(this, body, args, pipeables) - const opts: any = (options && "captureStackTrace" in options) ? options : { captureStackTrace, ...options } - return withSpan(effect, name, opts) + return fnApply({ + self: this, + body, + args, + pipeables, + spanName: name, + spanOptions: options, + errorDef, + errorCall + }) } } } -function fnApply(self: any, body: Function, args: Array, pipeables: Array) { +function fnApply(options: { + readonly self: any + readonly body: Function + readonly args: Array + readonly pipeables: Array + readonly spanName: string + readonly spanOptions: Tracer.SpanOptions + readonly errorDef: Error + readonly errorCall: Error +}) { let effect: Effect let fnError: any = undefined - if (isGeneratorFunction(body)) { - effect = gen(() => internalCall(() => body.apply(self, args))) + if (isGeneratorFunction(options.body)) { + effect = core.fromIterator(() => options.body.apply(options.self, options.args)) } else { try { - effect = body.apply(self, args) + effect = options.body.apply(options.self, options.args) } catch (error) { fnError = error effect = die(error) } } - if (pipeables.length === 0) { - return effect + if (options.pipeables.length > 0) { + try { + for (const x of options.pipeables) { + effect = x(effect) + } + } catch (error) { + effect = fnError + ? failCause(internalCause.sequential( + internalCause.die(fnError), + internalCause.die(error) + )) + : die(error) + } } - try { - for (const x of pipeables) { - effect = x(effect) + + let cache: false | string = false + const captureStackTrace = () => { + if (cache !== false) { + return cache + } + if (options.errorCall.stack) { + const stackDef = options.errorDef.stack!.trim().split("\n") + const stackCall = options.errorCall.stack.trim().split("\n") + cache = `${stackDef.slice(2).join("\n").trim()}\n${stackCall.slice(2).join("\n").trim()}` + return cache } - } catch (error) { - effect = fnError - ? failCause(internalCause.sequential( - internalCause.die(fnError), - internalCause.die(error) - )) - : die(error) } - return effect + const opts: any = (options.spanOptions && "captureStackTrace" in options.spanOptions) + ? options.spanOptions + : { captureStackTrace, ...options.spanOptions } + return withSpan(effect, options.spanName, opts) } + +/** + * Creates a function that returns an Effect. + * + * The function can be created using a generator function that can yield + * effects. + * + * `Effect.fnUntraced` also acts as a `pipe` function, allowing you to create a pipeline after the function definition. + * + * @example + * ```ts + * // Title: Creating a traced function with a generator function + * import { Effect } from "effect" + * + * const logExample = Effect.fnUntraced(function*(n: N) { + * yield* Effect.annotateCurrentSpan("n", n) + * yield* Effect.logInfo(`got: ${n}`) + * yield* Effect.fail(new Error()) + * }) + * + * Effect.runFork(logExample(100)) + * ``` + * + * @since 3.12.0 + * @category function + */ +export const fnUntraced: fn.Gen = (body: Function, ...pipeables: Array) => + pipeables.length === 0 + ? function(this: any, ...args: Array) { + return core.fromIterator(() => body.apply(this, args)) + } + : function(this: any, ...args: Array) { + let effect = core.fromIterator(() => body.apply(this, args)) + for (const x of pipeables) { + effect = x(effect) + } + return effect + } diff --git a/packages/effect/src/internal/cause.ts b/packages/effect/src/internal/cause.ts index 38bcbb4ddd1..3677c88d1f5 100644 --- a/packages/effect/src/internal/cause.ts +++ b/packages/effect/src/internal/cause.ts @@ -1073,7 +1073,7 @@ export const prettyErrorMessage = (u: unknown): string => { return stringifyCircular(u) } -const locationRegex = /\((.*)\)/ +const locationRegex = /\((.*)\)/g /** @internal */ export const spanToTrace = globalValue("effect/Tracer/spanToTrace", () => new WeakMap()) @@ -1105,9 +1105,15 @@ const prettyErrorStack = (message: string, stack: string, span?: Span | undefine if (typeof stackFn === "function") { const stack = stackFn() if (typeof stack === "string") { - const locationMatch = stack.match(locationRegex) - const location = locationMatch ? locationMatch[1] : stack.replace(/^at /, "") - out.push(` at ${current.name} (${location})`) + const locationMatchAll = stack.matchAll(locationRegex) + let match = false + for (const [, location] of locationMatchAll) { + match = true + out.push(` at ${current.name} (${location})`) + } + if (!match) { + out.push(` at ${current.name} (${stack.replace(/^at /, "")})`) + } } else { out.push(` at ${current.name}`) } diff --git a/packages/effect/src/internal/core.ts b/packages/effect/src/internal/core.ts index 2254c4ef588..0c1453c4690 100644 --- a/packages/effect/src/internal/core.ts +++ b/packages/effect/src/internal/core.ts @@ -1428,13 +1428,23 @@ export const whileLoop = ( } /* @internal */ -export const gen: typeof Effect.gen = function() { - const f = arguments.length === 1 ? arguments[0] : arguments[1].bind(arguments[0]) - return suspend(() => { +export const fromIterator = >, AEff>( + iterator: LazyArg> +): Effect.Effect< + AEff, + [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [YieldWrap>] ? R : never +> => + suspend(() => { const effect = new EffectPrimitive(OpCodes.OP_ITERATOR) as any - effect.effect_instruction_i0 = f(pipe) + effect.effect_instruction_i0 = iterator() return effect }) + +/* @internal */ +export const gen: typeof Effect.gen = function() { + const f = arguments.length === 1 ? arguments[0] : arguments[1].bind(arguments[0]) + return fromIterator(() => f(pipe)) } /* @internal */ From bfe78d775e78e922b4f350e012e1b25d3d681be5 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 19 Dec 2024 10:24:39 +1300 Subject: [PATCH 12/15] remove generics from HttpClient tag service (#4169) --- .changeset/friendly-kiwis-end.md | 8 + packages/platform/dtslint/HttpApiClient.ts | 6 +- packages/platform/src/HttpClient.ts | 188 ++++++++++--------- packages/platform/src/internal/httpClient.ts | 160 ++++++++-------- 4 files changed, 190 insertions(+), 172 deletions(-) create mode 100644 .changeset/friendly-kiwis-end.md diff --git a/.changeset/friendly-kiwis-end.md b/.changeset/friendly-kiwis-end.md new file mode 100644 index 00000000000..cc25e61c21e --- /dev/null +++ b/.changeset/friendly-kiwis-end.md @@ -0,0 +1,8 @@ +--- +"@effect/platform": minor +--- + +remove generics from HttpClient tag service + +Instead you can now use `HttpClient.With` to specify the error and +requirement types. diff --git a/packages/platform/dtslint/HttpApiClient.ts b/packages/platform/dtslint/HttpApiClient.ts index 7c4a8005b41..268366d9453 100644 --- a/packages/platform/dtslint/HttpApiClient.ts +++ b/packages/platform/dtslint/HttpApiClient.ts @@ -83,7 +83,7 @@ Effect.gen(function*() { const clientEndpointEffect = HttpApiClient.endpoint(TestApi, "Group1", "EndpointA") // $ExpectType never type _clientEndpointEffectError = Effect.Effect.Error - // $ExpectType "ApiErrorR" | "Group1ErrorR" | "EndpointAErrorR" | "EndpointASuccessR" | "EndpointASecurityErrorR" | "Group1SecurityErrorR" | "ApiSecurityErrorR" | HttpClient + // $ExpectType "ApiErrorR" | "Group1ErrorR" | "EndpointAErrorR" | "EndpointASuccessR" | "EndpointASecurityErrorR" | "Group1SecurityErrorR" | "ApiSecurityErrorR" | HttpClient type _clientEndpointEffectContext = Effect.Effect.Context const clientEndpoint = yield* clientEndpointEffect @@ -100,7 +100,7 @@ Effect.gen(function*() { const clientGroupEffect = HttpApiClient.group(TestApi, "Group1") // $ExpectType never type _clientGroupEffectError = Effect.Effect.Error - // $ExpectType "ApiErrorR" | "Group1ErrorR" | "EndpointAErrorR" | "EndpointASuccessR" | "EndpointBErrorR" | "EndpointBSuccessR" | "EndpointASecurityErrorR" | "EndpointBSecurityErrorR" | "Group1SecurityErrorR" | "ApiSecurityErrorR" | HttpClient + // $ExpectType "ApiErrorR" | "Group1ErrorR" | "EndpointAErrorR" | "EndpointASuccessR" | "EndpointBErrorR" | "EndpointBSuccessR" | "EndpointASecurityErrorR" | "EndpointBSecurityErrorR" | "Group1SecurityErrorR" | "ApiSecurityErrorR" | HttpClient type _clientGroupEffectContext = Effect.Effect.Context const clientGroup = yield* clientGroupEffect @@ -117,7 +117,7 @@ Effect.gen(function*() { const clientApiEffect = HttpApiClient.make(TestApi) // $ExpectType never type _clientApiEffectError = Effect.Effect.Error - // $ExpectType "ApiErrorR" | "Group1ErrorR" | "EndpointAErrorR" | "EndpointASuccessR" | "EndpointBErrorR" | "EndpointBSuccessR" | "EndpointASecurityErrorR" | "EndpointBSecurityErrorR" | "Group1SecurityErrorR" | "ApiSecurityErrorR" | "Group2ErrorR" | "EndpointCErrorR" | "EndpointCSuccessR" | HttpClient + // $ExpectType "ApiErrorR" | "Group1ErrorR" | "EndpointAErrorR" | "EndpointASuccessR" | "EndpointBErrorR" | "EndpointBSuccessR" | "EndpointASecurityErrorR" | "EndpointBSecurityErrorR" | "Group1SecurityErrorR" | "ApiSecurityErrorR" | "Group2ErrorR" | "EndpointCErrorR" | "EndpointCSuccessR" | HttpClient type _clientApiEffectContext = Effect.Effect.Context const clientApi = yield* clientApiEffect diff --git a/packages/platform/src/HttpClient.ts b/packages/platform/src/HttpClient.ts index c60b4a3f383..7b6f68b0eda 100644 --- a/packages/platform/src/HttpClient.ts +++ b/packages/platform/src/HttpClient.ts @@ -35,44 +35,52 @@ export type TypeId = typeof TypeId * @since 1.0.0 * @category models */ -export interface HttpClient extends Pipeable, Inspectable { - readonly [TypeId]: TypeId - readonly execute: (request: ClientRequest.HttpClientRequest) => Effect.Effect - - readonly get: ( - url: string | URL, - options?: ClientRequest.Options.NoBody - ) => Effect.Effect - readonly head: ( - url: string | URL, - options?: ClientRequest.Options.NoBody - ) => Effect.Effect - readonly post: ( - url: string | URL, - options?: ClientRequest.Options.NoUrl - ) => Effect.Effect - readonly patch: ( - url: string | URL, - options?: ClientRequest.Options.NoUrl - ) => Effect.Effect - readonly put: ( - url: string | URL, - options?: ClientRequest.Options.NoUrl - ) => Effect.Effect - readonly del: ( - url: string | URL, - options?: ClientRequest.Options.NoUrl - ) => Effect.Effect - readonly options: ( - url: string | URL, - options?: ClientRequest.Options.NoUrl - ) => Effect.Effect -} +export interface HttpClient extends HttpClient.With {} /** * @since 1.0.0 */ export declare namespace HttpClient { + /** + * @since 1.0.0 + * @category models + */ + export interface With extends Pipeable, Inspectable { + readonly [TypeId]: TypeId + readonly execute: ( + request: ClientRequest.HttpClientRequest + ) => Effect.Effect + + readonly get: ( + url: string | URL, + options?: ClientRequest.Options.NoBody + ) => Effect.Effect + readonly head: ( + url: string | URL, + options?: ClientRequest.Options.NoBody + ) => Effect.Effect + readonly post: ( + url: string | URL, + options?: ClientRequest.Options.NoUrl + ) => Effect.Effect + readonly patch: ( + url: string | URL, + options?: ClientRequest.Options.NoUrl + ) => Effect.Effect + readonly put: ( + url: string | URL, + options?: ClientRequest.Options.NoUrl + ) => Effect.Effect + readonly del: ( + url: string | URL, + options?: ClientRequest.Options.NoUrl + ) => Effect.Effect + readonly options: ( + url: string | URL, + options?: ClientRequest.Options.NoUrl + ) => Effect.Effect + } + /** * @since 1.0.0 * @category models @@ -203,11 +211,11 @@ export const options: ( export const catchAll: { ( f: (e: E) => Effect.Effect - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: (e: E) => Effect.Effect - ): HttpClient + ): HttpClient.With } = internal.catchAll /** @@ -218,12 +226,12 @@ export const catchTag: { ( tag: K, f: (e: Extract) => Effect.Effect - ): (self: HttpClient) => HttpClient, R1 | R> + ): (self: HttpClient.With) => HttpClient.With, R1 | R> ( - self: HttpClient, + self: HttpClient.With, tag: K, f: (e: Extract) => Effect.Effect - ): HttpClient, R1 | R> + ): HttpClient.With, R1 | R> } = internal.catchTag /** @@ -243,8 +251,8 @@ export const catchTags: { >( cases: Cases ): ( - self: HttpClient - ) => HttpClient< + self: HttpClient.With + ) => HttpClient.With< | Exclude | { [K in keyof Cases]: Cases[K] extends (...args: Array) => Effect.Effect ? E : never @@ -265,9 +273,9 @@ export const catchTags: { } & (unknown extends E ? {} : { [K in Exclude["_tag"]>]: never }) >( - self: HttpClient, + self: HttpClient.With, cases: Cases - ): HttpClient< + ): HttpClient.With< | Exclude | { [K in keyof Cases]: Cases[K] extends (...args: Array) => Effect.Effect ? E : never @@ -289,12 +297,12 @@ export const filterOrElse: { ( predicate: Predicate.Predicate, orElse: (response: ClientResponse.HttpClientResponse) => Effect.Effect - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, predicate: Predicate.Predicate, orElse: (response: ClientResponse.HttpClientResponse) => Effect.Effect - ): HttpClient + ): HttpClient.With } = internal.filterOrElse /** @@ -307,12 +315,12 @@ export const filterOrFail: { ( predicate: Predicate.Predicate, orFailWith: (response: ClientResponse.HttpClientResponse) => E2 - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, predicate: Predicate.Predicate, orFailWith: (response: ClientResponse.HttpClientResponse) => E2 - ): HttpClient + ): HttpClient.With } = internal.filterOrFail /** @@ -322,8 +330,8 @@ export const filterOrFail: { * @category filters */ export const filterStatus: { - (f: (status: number) => boolean): (self: HttpClient) => HttpClient - (self: HttpClient, f: (status: number) => boolean): HttpClient + (f: (status: number) => boolean): (self: HttpClient.With) => HttpClient.With + (self: HttpClient.With, f: (status: number) => boolean): HttpClient.With } = internal.filterStatus /** @@ -332,7 +340,7 @@ export const filterStatus: { * @since 1.0.0 * @category filters */ -export const filterStatusOk: (self: HttpClient) => HttpClient = +export const filterStatusOk: (self: HttpClient.With) => HttpClient.With = internal.filterStatusOk /** @@ -344,7 +352,7 @@ export const makeWith: ( request: Effect.Effect ) => Effect.Effect, preprocess: HttpClient.Preprocess -) => HttpClient = internal.makeWith +) => HttpClient.With = internal.makeWith /** * @since 1.0.0 @@ -369,14 +377,14 @@ export const transform: { effect: Effect.Effect, request: ClientRequest.HttpClientRequest ) => Effect.Effect - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: ( effect: Effect.Effect, request: ClientRequest.HttpClientRequest ) => Effect.Effect - ): HttpClient + ): HttpClient.With } = internal.transform /** @@ -388,13 +396,13 @@ export const transformResponse: { f: ( effect: Effect.Effect ) => Effect.Effect - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: ( effect: Effect.Effect ) => Effect.Effect - ): HttpClient + ): HttpClient.With } = internal.transformResponse /** @@ -406,11 +414,11 @@ export const transformResponse: { export const mapRequest: { ( f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest - ): HttpClient + ): HttpClient.With } = internal.mapRequest /** @@ -422,11 +430,11 @@ export const mapRequest: { export const mapRequestEffect: { ( f: (a: ClientRequest.HttpClientRequest) => Effect.Effect - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: (a: ClientRequest.HttpClientRequest) => Effect.Effect - ): HttpClient + ): HttpClient.With } = internal.mapRequestEffect /** @@ -438,11 +446,11 @@ export const mapRequestEffect: { export const mapRequestInput: { ( f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest - ): HttpClient + ): HttpClient.With } = internal.mapRequestInput /** @@ -454,11 +462,11 @@ export const mapRequestInput: { export const mapRequestInputEffect: { ( f: (a: ClientRequest.HttpClientRequest) => Effect.Effect - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: (a: ClientRequest.HttpClientRequest) => Effect.Effect - ): HttpClient + ): HttpClient.With } = internal.mapRequestInputEffect /** @@ -470,7 +478,7 @@ export declare namespace Retry { * @since 1.0.0 * @category error handling */ - export type Return> = HttpClient< + export type Return> = HttpClient.With< | (O extends { schedule: Schedule.Schedule } ? E : O extends { until: Predicate.Refinement } ? E2 : E) @@ -490,10 +498,12 @@ export declare namespace Retry { * @category error handling */ export const retry: { - >(options: O): (self: HttpClient) => Retry.Return - (policy: Schedule.Schedule, R1>): (self: HttpClient) => HttpClient - >(self: HttpClient, options: O): Retry.Return - (self: HttpClient, policy: Schedule.Schedule): HttpClient + >(options: O): (self: HttpClient.With) => Retry.Return + ( + policy: Schedule.Schedule, R1> + ): (self: HttpClient.With) => HttpClient.With + >(self: HttpClient.With, options: O): Retry.Return + (self: HttpClient.With, policy: Schedule.Schedule): HttpClient.With } = internal.retry /** @@ -512,15 +522,15 @@ export const retryTransient: { readonly schedule?: Schedule.Schedule, R1> readonly times?: number } | Schedule.Schedule, R1> - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, options: { readonly while?: Predicate.Predicate> readonly schedule?: Schedule.Schedule, R1> readonly times?: number } | Schedule.Schedule, R1> - ): HttpClient + ): HttpClient.With } = internal.retryTransient /** @@ -532,11 +542,11 @@ export const retryTransient: { export const tap: { <_, E2, R2>( f: (response: ClientResponse.HttpClientResponse) => Effect.Effect<_, E2, R2> - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: (response: ClientResponse.HttpClientResponse) => Effect.Effect<_, E2, R2> - ): HttpClient + ): HttpClient.With } = internal.tap /** @@ -548,11 +558,11 @@ export const tap: { export const tapRequest: { <_, E2, R2>( f: (a: ClientRequest.HttpClientRequest) => Effect.Effect<_, E2, R2> - ): (self: HttpClient) => HttpClient + ): (self: HttpClient.With) => HttpClient.With ( - self: HttpClient, + self: HttpClient.With, f: (a: ClientRequest.HttpClientRequest) => Effect.Effect<_, E2, R2> - ): HttpClient + ): HttpClient.With } = internal.tapRequest /** @@ -562,8 +572,8 @@ export const tapRequest: { * @category cookies */ export const withCookiesRef: { - (ref: Ref): (self: HttpClient) => HttpClient - (self: HttpClient, ref: Ref): HttpClient + (ref: Ref): (self: HttpClient.With) => HttpClient.With + (self: HttpClient.With, ref: Ref): HttpClient.With } = internal.withCookiesRef /** @@ -573,8 +583,8 @@ export const withCookiesRef: { * @category redirects */ export const followRedirects: { - (maxRedirects?: number | undefined): (self: HttpClient) => HttpClient - (self: HttpClient, maxRedirects?: number | undefined): HttpClient + (maxRedirects?: number | undefined): (self: HttpClient.With) => HttpClient.With + (self: HttpClient.With, maxRedirects?: number | undefined): HttpClient.With } = internal.followRedirects /** diff --git a/packages/platform/src/internal/httpClient.ts b/packages/platform/src/internal/httpClient.ts index 1c684b7b5e7..0a62df595bb 100644 --- a/packages/platform/src/internal/httpClient.ts +++ b/packages/platform/src/internal/httpClient.ts @@ -100,9 +100,9 @@ const ClientProto = { } } -const isClient = (u: unknown): u is Client.HttpClient => Predicate.hasProperty(u, TypeId) +const isClient = (u: unknown): u is Client.HttpClient.With => Predicate.hasProperty(u, TypeId) -interface HttpClientImpl extends Client.HttpClient { +interface HttpClientImpl extends Client.HttpClient.With { readonly preprocess: Client.HttpClient.Preprocess readonly postprocess: Client.HttpClient.Postprocess } @@ -113,7 +113,7 @@ export const makeWith = ( request: Effect.Effect ) => Effect.Effect, preprocess: Client.HttpClient.Preprocess -): Client.HttpClient => { +): Client.HttpClient.With => { const self = Object.create(ClientProto) self.preprocess = preprocess self.postprocess = postprocess @@ -226,14 +226,14 @@ export const transform = dual< effect: Effect.Effect, request: ClientRequest.HttpClientRequest ) => Effect.Effect - ) => (self: Client.HttpClient) => Client.HttpClient, + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: ( effect: Effect.Effect, request: ClientRequest.HttpClientRequest ) => Effect.Effect - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => { const client = self as HttpClientImpl return makeWith( @@ -247,18 +247,18 @@ export const filterStatus = dual< ( f: (status: number) => boolean ) => ( - self: Client.HttpClient - ) => Client.HttpClient, + self: Client.HttpClient.With + ) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: (status: number) => boolean - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => transformResponse(self, Effect.flatMap(internalResponse.filterStatus(f)))) /** @internal */ export const filterStatusOk = ( - self: Client.HttpClient -): Client.HttpClient => + self: Client.HttpClient.With +): Client.HttpClient.With => transformResponse(self, Effect.flatMap(internalResponse.filterStatusOk)) /** @internal */ @@ -267,13 +267,13 @@ export const transformResponse = dual< f: ( effect: Effect.Effect ) => Effect.Effect - ) => (self: Client.HttpClient) => Client.HttpClient, + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: ( effect: Effect.Effect ) => Effect.Effect - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => { const client = self as HttpClientImpl return makeWith((request) => f(client.postprocess(request)), client.preprocess) @@ -284,7 +284,7 @@ export const catchTag: { ( tag: K, f: (e: Extract) => Effect.Effect - ): (self: Client.HttpClient) => Client.HttpClient, R1 | R> + ): (self: Client.HttpClient.With) => Client.HttpClient.With, R1 | R> < R, E, @@ -292,10 +292,10 @@ export const catchTag: { R1, E1 >( - self: Client.HttpClient, + self: Client.HttpClient.With, tag: K, f: (e: Extract) => Effect.Effect - ): Client.HttpClient, R1 | R> + ): Client.HttpClient.With, R1 | R> } = dual( 3, < @@ -305,10 +305,10 @@ export const catchTag: { R1, E1 >( - self: Client.HttpClient, + self: Client.HttpClient.With, tag: K, f: (e: Extract) => Effect.Effect - ): Client.HttpClient, R1 | R> => transformResponse(self, Effect.catchTag(tag, f)) + ): Client.HttpClient.With, R1 | R> => transformResponse(self, Effect.catchTag(tag, f)) ) /** @internal */ @@ -332,7 +332,7 @@ export const catchTags: { }) >( cases: Cases - ): (self: Client.HttpClient) => Client.HttpClient< + ): (self: Client.HttpClient.With) => Client.HttpClient.With< | Exclude | { [K in keyof Cases]: Cases[K] extends ( @@ -367,9 +367,9 @@ export const catchTags: { ]: never }) >( - self: Client.HttpClient, + self: Client.HttpClient.With, cases: Cases - ): Client.HttpClient< + ): Client.HttpClient.With< | Exclude | { [K in keyof Cases]: Cases[K] extends ( @@ -406,9 +406,9 @@ export const catchTags: { ]: never }) >( - self: Client.HttpClient, + self: Client.HttpClient.With, cases: Cases - ): Client.HttpClient< + ): Client.HttpClient.With< | Exclude | { [K in keyof Cases]: Cases[K] extends ( @@ -430,17 +430,17 @@ export const catchTags: { export const catchAll: { ( f: (e: E) => Effect.Effect - ): (self: Client.HttpClient) => Client.HttpClient + ): (self: Client.HttpClient.With) => Client.HttpClient.With ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: (e: E) => Effect.Effect - ): Client.HttpClient + ): Client.HttpClient.With } = dual( 2, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: (e: E) => Effect.Effect - ): Client.HttpClient => transformResponse(self, Effect.catchAll(f)) + ): Client.HttpClient.With => transformResponse(self, Effect.catchAll(f)) ) /** @internal */ @@ -449,13 +449,13 @@ export const filterOrElse: { predicate: Predicate.Predicate, orElse: (response: ClientResponse.HttpClientResponse) => Effect.Effect ): ( - self: Client.HttpClient - ) => Client.HttpClient + self: Client.HttpClient.With + ) => Client.HttpClient.With ( - self: Client.HttpClient, + self: Client.HttpClient.With, predicate: Predicate.Predicate, orElse: (response: ClientResponse.HttpClientResponse) => Effect.Effect - ): Client.HttpClient + ): Client.HttpClient.With } = dual(3, (self, f, orElse) => transformResponse(self, Effect.filterOrElse(f, orElse))) /** @internal */ @@ -463,23 +463,23 @@ export const filterOrFail: { ( predicate: Predicate.Predicate, orFailWith: (response: ClientResponse.HttpClientResponse) => E2 - ): (self: Client.HttpClient) => Client.HttpClient + ): (self: Client.HttpClient.With) => Client.HttpClient.With ( - self: Client.HttpClient, + self: Client.HttpClient.With, predicate: Predicate.Predicate, orFailWith: (response: ClientResponse.HttpClientResponse) => E2 - ): Client.HttpClient + ): Client.HttpClient.With } = dual(3, (self, f, orFailWith) => transformResponse(self, Effect.filterOrFail(f, orFailWith))) /** @internal */ export const mapRequest = dual< ( f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest - ) => (self: Client.HttpClient) => Client.HttpClient, + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => { const client = self as HttpClientImpl return makeWith(client.postprocess, (request) => Effect.map(client.preprocess(request), f)) @@ -492,14 +492,14 @@ export const mapRequestEffect = dual< a: ClientRequest.HttpClientRequest ) => Effect.Effect ) => ( - self: Client.HttpClient - ) => Client.HttpClient, + self: Client.HttpClient.With + ) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: ( a: ClientRequest.HttpClientRequest ) => Effect.Effect - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => { const client = self as HttpClientImpl return makeWith(client.postprocess as any, (request) => Effect.flatMap(client.preprocess(request), f)) @@ -509,11 +509,11 @@ export const mapRequestEffect = dual< export const mapRequestInput = dual< ( f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest - ) => (self: Client.HttpClient) => Client.HttpClient, + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: (a: ClientRequest.HttpClientRequest) => ClientRequest.HttpClientRequest - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => { const client = self as HttpClientImpl return makeWith(client.postprocess, (request) => client.preprocess(f(request))) @@ -526,14 +526,14 @@ export const mapRequestInputEffect = dual< a: ClientRequest.HttpClientRequest ) => Effect.Effect ) => ( - self: Client.HttpClient - ) => Client.HttpClient, + self: Client.HttpClient.With + ) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: ( a: ClientRequest.HttpClientRequest ) => Effect.Effect - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => { const client = self as HttpClientImpl return makeWith(client.postprocess as any, (request) => Effect.flatMap(f(request), client.preprocess)) @@ -543,24 +543,24 @@ export const mapRequestInputEffect = dual< export const retry: { >( options: O - ): (self: Client.HttpClient) => Client.Retry.Return + ): (self: Client.HttpClient.With) => Client.Retry.Return ( policy: Schedule.Schedule, R1> - ): (self: Client.HttpClient) => Client.HttpClient + ): (self: Client.HttpClient.With) => Client.HttpClient.With >( - self: Client.HttpClient, + self: Client.HttpClient.With, options: O ): Client.Retry.Return ( - self: Client.HttpClient, + self: Client.HttpClient.With, policy: Schedule.Schedule - ): Client.HttpClient + ): Client.HttpClient.With } = dual( 2, ( - self: Client.HttpClient, + self: Client.HttpClient.With, policy: Schedule.Schedule - ): Client.HttpClient => transformResponse(self, Effect.retry(policy)) + ): Client.HttpClient.With => transformResponse(self, Effect.retry(policy)) ) /** @internal */ @@ -571,25 +571,25 @@ export const retryTransient: { readonly schedule?: Schedule.Schedule, R1> readonly times?: number } | Schedule.Schedule, R1> - ): (self: Client.HttpClient) => Client.HttpClient + ): (self: Client.HttpClient.With) => Client.HttpClient.With ( - self: Client.HttpClient, + self: Client.HttpClient.With, options: { readonly while?: Predicate.Predicate> readonly schedule?: Schedule.Schedule, R1> readonly times?: number } | Schedule.Schedule, R1> - ): Client.HttpClient + ): Client.HttpClient.With } = dual( 2, ( - self: Client.HttpClient, + self: Client.HttpClient.With, options: { readonly while?: Predicate.Predicate> readonly schedule?: Schedule.Schedule, R1> readonly times?: number } | Schedule.Schedule, R1> - ): Client.HttpClient => + ): Client.HttpClient.With => transformResponse( self, Effect.retry({ @@ -614,11 +614,11 @@ const isTransientHttpError = (error: unknown) => export const tap = dual< <_, E2, R2>( f: (response: ClientResponse.HttpClientResponse) => Effect.Effect<_, E2, R2> - ) => (self: Client.HttpClient) => Client.HttpClient, + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: (response: ClientResponse.HttpClientResponse) => Effect.Effect<_, E2, R2> - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => transformResponse(self, Effect.tap(f))) /** @internal */ @@ -626,12 +626,12 @@ export const tapRequest = dual< <_, E2, R2>( f: (a: ClientRequest.HttpClientRequest) => Effect.Effect<_, E2, R2> ) => ( - self: Client.HttpClient - ) => Client.HttpClient, + self: Client.HttpClient.With + ) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, f: (a: ClientRequest.HttpClientRequest) => Effect.Effect<_, E2, R2> - ) => Client.HttpClient + ) => Client.HttpClient.With >(2, (self, f) => { const client = self as HttpClientImpl return makeWith(client.postprocess as any, (request) => Effect.tap(client.preprocess(request), f)) @@ -641,17 +641,17 @@ export const tapRequest = dual< export const withCookiesRef = dual< ( ref: Ref.Ref - ) => (self: Client.HttpClient) => Client.HttpClient, + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, ref: Ref.Ref - ) => Client.HttpClient + ) => Client.HttpClient.With >( 2, ( - self: Client.HttpClient, + self: Client.HttpClient.With, ref: Ref.Ref - ): Client.HttpClient => { + ): Client.HttpClient.With => { const client = self as HttpClientImpl return makeWith( (request: Effect.Effect) => @@ -676,15 +676,15 @@ export const withCookiesRef = dual< export const followRedirects = dual< ( maxRedirects?: number | undefined - ) => (self: Client.HttpClient) => Client.HttpClient, + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, ( - self: Client.HttpClient, + self: Client.HttpClient.With, maxRedirects?: number | undefined - ) => Client.HttpClient + ) => Client.HttpClient.With >((args) => isClient(args[0]), ( - self: Client.HttpClient, + self: Client.HttpClient.With, maxRedirects?: number | undefined -): Client.HttpClient => { +): Client.HttpClient.With => { const client = self as HttpClientImpl return makeWith( (request) => { From cd543e46cb2bd4241a1a28b0693a62678dfa9452 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 17 Dec 2024 16:22:51 +1300 Subject: [PATCH 13/15] ensure Redactable works with Console.log --- .changeset/rare-icons-hear.md | 6 ++++ packages/effect/src/Inspectable.ts | 31 +++++--------------- packages/effect/src/internal/fiberRuntime.ts | 26 ++++++++-------- packages/platform/src/Headers.ts | 9 ++++-- packages/platform/test/Headers.test.ts | 17 +++++++---- packages/platform/test/HttpClient.test.ts | 5 +++- 6 files changed, 48 insertions(+), 46 deletions(-) create mode 100644 .changeset/rare-icons-hear.md diff --git a/.changeset/rare-icons-hear.md b/.changeset/rare-icons-hear.md new file mode 100644 index 00000000000..227bd7dcebc --- /dev/null +++ b/.changeset/rare-icons-hear.md @@ -0,0 +1,6 @@ +--- +"effect": minor +"@effect/platform": patch +--- + +ensure Redactable works with Console.log diff --git a/packages/effect/src/Inspectable.ts b/packages/effect/src/Inspectable.ts index 0116aaf3b0b..7d99db7b189 100644 --- a/packages/effect/src/Inspectable.ts +++ b/packages/effect/src/Inspectable.ts @@ -2,8 +2,8 @@ * @since 2.0.0 */ +import type { RuntimeFiber } from "./Fiber.js" import type * as FiberRefs from "./FiberRefs.js" -import { globalValue } from "./GlobalValue.js" import { hasProperty, isFunction } from "./Predicate.js" /** @@ -114,9 +114,7 @@ export const stringifyCircular = (obj: unknown, whitespace?: number | string | u typeof value === "object" && value !== null ? cache.includes(value) ? undefined // circular reference - : cache.push(value) && (redactableState.fiberRefs !== undefined && isRedactable(value) - ? value[symbolRedactable](redactableState.fiberRefs) - : value) + : cache.push(value) && (isRedactable(value) ? redact(value) : value) : value, whitespace ) @@ -145,31 +143,18 @@ export const symbolRedactable: unique symbol = Symbol.for("effect/Inspectable/Re export const isRedactable = (u: unknown): u is Redactable => typeof u === "object" && u !== null && symbolRedactable in u -const redactableState = globalValue("effect/Inspectable/redactableState", () => ({ - fiberRefs: undefined as FiberRefs.FiberRefs | undefined -})) - -/** - * @since 3.10.0 - * @category redactable - */ -export const withRedactableContext = (context: FiberRefs.FiberRefs, f: () => A): A => { - const prev = redactableState.fiberRefs - redactableState.fiberRefs = context - try { - return f() - } finally { - redactableState.fiberRefs = prev - } -} +const currentFiberURI = "effect/FiberCurrent" /** * @since 3.10.0 * @category redactable */ export const redact = (u: unknown): unknown => { - if (isRedactable(u) && redactableState.fiberRefs !== undefined) { - return u[symbolRedactable](redactableState.fiberRefs) + if (isRedactable(u)) { + const fiber = (globalThis as any)[currentFiberURI] as RuntimeFiber | undefined + if (fiber !== undefined) { + return u[symbolRedactable](fiber.getFiberRefs()) + } } return u } diff --git a/packages/effect/src/internal/fiberRuntime.ts b/packages/effect/src/internal/fiberRuntime.ts index af4564c312a..466707c7bda 100644 --- a/packages/effect/src/internal/fiberRuntime.ts +++ b/packages/effect/src/internal/fiberRuntime.ts @@ -859,20 +859,18 @@ export class FiberRuntime extends Effectable.Class 0) { const clockService = Context.get(this.getFiberRef(defaultServices.currentServices), clock.clockTag) const date = new Date(clockService.unsafeCurrentTimeMillis()) - Inspectable.withRedactableContext(contextMap, () => { - for (const logger of loggers) { - logger.log({ - fiberId: this.id(), - logLevel, - message, - cause, - context: contextMap, - spans, - annotations, - date - }) - } - }) + for (const logger of loggers) { + logger.log({ + fiberId: this.id(), + logLevel, + message, + cause, + context: contextMap, + spans, + annotations, + date + }) + } } } diff --git a/packages/platform/src/Headers.ts b/packages/platform/src/Headers.ts index 830e713665d..83c27d96efd 100644 --- a/packages/platform/src/Headers.ts +++ b/packages/platform/src/Headers.ts @@ -5,7 +5,7 @@ import { FiberRefs } from "effect" import * as FiberRef from "effect/FiberRef" import { dual, identity } from "effect/Function" import { globalValue } from "effect/GlobalValue" -import { type Redactable, symbolRedactable } from "effect/Inspectable" +import * as Inspectable from "effect/Inspectable" import type * as Option from "effect/Option" import * as Predicate from "effect/Predicate" import * as Record from "effect/Record" @@ -36,18 +36,21 @@ export const isHeaders = (u: unknown): u is Headers => Predicate.hasProperty(u, * @since 1.0.0 * @category models */ -export interface Headers extends Redactable { +export interface Headers extends Inspectable.Redactable { readonly [HeadersTypeId]: HeadersTypeId readonly [key: string]: string } const Proto = Object.assign(Object.create(null), { [HeadersTypeId]: HeadersTypeId, - [symbolRedactable]( + [Inspectable.symbolRedactable]( this: Headers, fiberRefs: FiberRefs.FiberRefs ): Record> { return redact(this, FiberRefs.getOrDefault(fiberRefs, currentRedactedNames)) + }, + [Inspectable.NodeInspectSymbol]() { + return Inspectable.redact(this) } }) diff --git a/packages/platform/test/Headers.test.ts b/packages/platform/test/Headers.test.ts index 95abe753c67..aea0a4745e3 100644 --- a/packages/platform/test/Headers.test.ts +++ b/packages/platform/test/Headers.test.ts @@ -1,11 +1,11 @@ import * as Headers from "@effect/platform/Headers" import { assert, describe, it } from "@effect/vitest" -import { Effect, FiberId, FiberRef, FiberRefs, HashSet, Inspectable, Logger } from "effect" +import { Console, Effect, FiberId, FiberRef, FiberRefs, HashSet, Inspectable, Logger } from "effect" import * as Redacted from "effect/Redacted" describe("Headers", () => { describe("Redactable", () => { - it("one key", () => { + it("one key", async () => { const headers = Headers.fromInput({ "Content-Type": "application/json", "Authorization": "Bearer some-token", @@ -20,8 +20,11 @@ describe("Headers", () => { ] as const ]) ) - const r = Inspectable.withRedactableContext(fiberRefs, () => Inspectable.toStringUnknown(headers)) - const redacted = JSON.parse(r) + const r = Effect.gen(function*() { + yield* Effect.setFiberRefs(fiberRefs) + return Inspectable.toStringUnknown(headers) + }).pipe(Effect.runSync) + const redacted = JSON.parse(r) as any assert.deepEqual(redacted, { "content-type": "application/json", @@ -45,7 +48,10 @@ describe("Headers", () => { ] as const ]) ) - const r = Inspectable.withRedactableContext(fiberRefs, () => Inspectable.toStringUnknown({ headers })) + const r = Effect.gen(function*() { + yield* Effect.setFiberRefs(fiberRefs) + return Inspectable.toStringUnknown({ headers }) + }).pipe(Effect.runSync) const redacted = JSON.parse(r) as { headers: unknown } assert.deepEqual(redacted.headers, { @@ -72,6 +78,7 @@ describe("Headers", () => { yield* Effect.log(headers).pipe( Effect.annotateLogs({ headers }) ) + yield* Console.log(headers) assert.include(messages[0], "application/json") assert.notInclude(messages[0], "some-token") assert.notInclude(messages[0], "some-key") diff --git a/packages/platform/test/HttpClient.test.ts b/packages/platform/test/HttpClient.test.ts index a3adc1f94e0..d6993f9fd22 100644 --- a/packages/platform/test/HttpClient.test.ts +++ b/packages/platform/test/HttpClient.test.ts @@ -208,7 +208,10 @@ describe("HttpClient", () => { ] as const ]) ) - const r = Inspectable.withRedactableContext(fiberRefs, () => Inspectable.toStringUnknown(request)) + const r = Effect.gen(function*() { + yield* Effect.setFiberRefs(fiberRefs) + return Inspectable.toStringUnknown(request) + }).pipe(Effect.runSync) const redacted = JSON.parse(r) assert.deepStrictEqual(redacted, { From 8ddf0c35b10a58219165acfc7bb8d9194aea4ceb Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 20 Dec 2024 10:18:11 +1300 Subject: [PATCH 14/15] capture redactable context at creation --- packages/effect/src/Inspectable.ts | 64 +++++++++++---- .../src/internal/httpClient.ts | 4 +- .../platform-bun/src/internal/httpServer.ts | 6 +- .../src/internal/httpClientUndici.ts | 6 +- .../src/internal/httpIncomingMessage.ts | 9 +-- .../platform-node/src/internal/httpServer.ts | 24 +++--- packages/platform/src/Headers.ts | 77 ++++++++++++------- packages/platform/src/internal/httpClient.ts | 2 +- .../src/internal/httpClientRequest.ts | 2 +- .../src/internal/httpClientResponse.ts | 6 +- .../platform/src/internal/httpMiddleware.ts | 2 +- .../platform/src/internal/httpPlatform.ts | 4 +- .../src/internal/httpServerRequest.ts | 6 +- .../src/internal/httpServerResponse.ts | 20 ++--- packages/platform/test/Headers.test.ts | 49 ++++-------- packages/platform/test/HttpClient.test.ts | 17 +--- packages/rpc/src/Rpc.ts | 15 ++-- packages/rpc/src/RpcResolver.ts | 10 +-- 18 files changed, 172 insertions(+), 151 deletions(-) diff --git a/packages/effect/src/Inspectable.ts b/packages/effect/src/Inspectable.ts index 7d99db7b189..4467c6ebaf3 100644 --- a/packages/effect/src/Inspectable.ts +++ b/packages/effect/src/Inspectable.ts @@ -1,9 +1,9 @@ /** * @since 2.0.0 */ - +import * as Context from "./Context.js" import type { RuntimeFiber } from "./Fiber.js" -import type * as FiberRefs from "./FiberRefs.js" +import { globalValue } from "./GlobalValue.js" import { hasProperty, isFunction } from "./Predicate.js" /** @@ -126,15 +126,15 @@ export const stringifyCircular = (obj: unknown, whitespace?: number | string | u * @since 3.10.0 * @category redactable */ -export interface Redactable { - readonly [symbolRedactable]: (fiberRefs: FiberRefs.FiberRefs) => unknown -} +export const symbolRedactable: unique symbol = Symbol.for("effect/Inspectable/Redactable") /** * @since 3.10.0 * @category redactable */ -export const symbolRedactable: unique symbol = Symbol.for("effect/Inspectable/Redactable") +export interface Redactable { + [symbolRedactable](): unknown +} /** * @since 3.10.0 @@ -143,18 +143,56 @@ export const symbolRedactable: unique symbol = Symbol.for("effect/Inspectable/Re export const isRedactable = (u: unknown): u is Redactable => typeof u === "object" && u !== null && symbolRedactable in u -const currentFiberURI = "effect/FiberCurrent" - /** * @since 3.10.0 * @category redactable */ export const redact = (u: unknown): unknown => { - if (isRedactable(u)) { - const fiber = (globalThis as any)[currentFiberURI] as RuntimeFiber | undefined - if (fiber !== undefined) { - return u[symbolRedactable](fiber.getFiberRefs()) + return isRedactable(u) ? u[symbolRedactable]() : u +} + +const redactableContext = globalValue("effect/Inspectable/redactableContext", () => new WeakMap()) + +/** + * @since 3.12.0 + * @category redactable + */ +export const makeRedactableContext = (make: (context: Context.Context) => A): { + readonly register: (self: unknown, context?: Context.Context, input?: unknown) => void + readonly get: (u: unknown) => A +} => ({ + register(self, context, input) { + if (input && redactableContext.has(input)) { + redactableContext.set(self, redactableContext.get(input)) + return } + redactableContext.set(self, make(context ?? currentContext() ?? Context.empty())) + }, + get(u) { + return redactableContext.has(u) ? redactableContext.get(u) : make(currentContext() ?? Context.empty()) } - return u +}) + +/** + * @since 3.12.0 + * @category redactable + */ +export const RedactableClass = () => +(options: { + readonly context: (fiberRefs: Context.Context) => A + readonly redact: (self: Self, context: A) => unknown +}): new(context?: Context.Context, input?: unknown) => Redactable => { + const redactable = makeRedactableContext(options.context) + function Redactable(this: any, context?: Context.Context, input?: unknown) { + redactable.register(this, context, input) + } + Redactable.prototype[symbolRedactable] = function() { + return options.redact(this, redactable.get(this)) + } + return Redactable as any +} + +const currentContext = (): Context.Context | undefined => { + const fiber = (globalThis as any)["effect/FiberCurrent"] as RuntimeFiber | undefined + return fiber ? fiber.currentContext : undefined } diff --git a/packages/platform-browser/src/internal/httpClient.ts b/packages/platform-browser/src/internal/httpClient.ts index 74139107690..0689e4e0e9d 100644 --- a/packages/platform-browser/src/internal/httpClient.ts +++ b/packages/platform-browser/src/internal/httpClient.ts @@ -142,12 +142,12 @@ export abstract class IncomingMessageImpl extends Inspectable.Class return this._headers } if (this._rawHeaderString === "") { - return this._headers = Headers.empty + return this._headers = Headers.empty() } const parser = HeaderParser.make() const result = parser(encoder.encode(this._rawHeaderString + "\r\n"), 0) this._rawHeaders = result._tag === "Headers" ? result.headers : undefined - const parsed = result._tag === "Headers" ? Headers.fromInput(result.headers) : Headers.empty + const parsed = result._tag === "Headers" ? Headers.fromInput(result.headers) : Headers.empty() return this._headers = parsed } diff --git a/packages/platform-bun/src/internal/httpServer.ts b/packages/platform-bun/src/internal/httpServer.ts index c85cc148805..81f24e93bdc 100644 --- a/packages/platform-bun/src/internal/httpServer.ts +++ b/packages/platform-bun/src/internal/httpServer.ts @@ -208,6 +208,7 @@ function wsDefaultRun(this: WebSocketContext, _: Uint8Array | string) { class ServerRequestImpl extends Inspectable.Class implements ServerRequest.HttpServerRequest { readonly [ServerRequest.TypeId]: ServerRequest.TypeId readonly [IncomingMessage.TypeId]: IncomingMessage.TypeId + readonly headers: Headers.Headers constructor( readonly source: Request, public resolve: (response: Response) => void, @@ -219,6 +220,7 @@ class ServerRequestImpl extends Inspectable.Class implements ServerRequest.HttpS super() this[ServerRequest.TypeId] = ServerRequest.TypeId this[IncomingMessage.TypeId] = IncomingMessage.TypeId + this.headers = headersOverride ?? Headers.fromInput(source.headers) } toJSON(): unknown { return IncomingMessage.inspect(this, { @@ -254,10 +256,6 @@ class ServerRequestImpl extends Inspectable.Class implements ServerRequest.HttpS ? Option.some(this.remoteAddressOverride) : Option.fromNullable(this.bunServer.requestIP(this.source)?.address) } - get headers(): Headers.Headers { - this.headersOverride ??= Headers.fromInput(this.source.headers) - return this.headersOverride - } private cachedCookies: ReadonlyRecord | undefined get cookies() { diff --git a/packages/platform-node/src/internal/httpClientUndici.ts b/packages/platform-node/src/internal/httpClientUndici.ts index fb67f4380b9..9c25499edb4 100644 --- a/packages/platform-node/src/internal/httpClientUndici.ts +++ b/packages/platform-node/src/internal/httpClientUndici.ts @@ -97,6 +97,7 @@ function noopErrorHandler(_: any) {} class ClientResponseImpl extends Inspectable.Class implements ClientResponse.HttpClientResponse { readonly [IncomingMessage.TypeId]: IncomingMessage.TypeId readonly [ClientResponse.TypeId]: ClientResponse.TypeId + readonly headers: Headers.Headers constructor( readonly request: ClientRequest.HttpClientRequest, @@ -105,6 +106,7 @@ class ClientResponseImpl extends Inspectable.Class implements ClientResponse.Htt super() this[IncomingMessage.TypeId] = IncomingMessage.TypeId this[ClientResponse.TypeId] = ClientResponse.TypeId + this.headers = Headers.fromInput(this.source.headers) source.body.on("error", noopErrorHandler) } @@ -128,10 +130,6 @@ class ClientResponseImpl extends Inspectable.Class implements ClientResponse.Htt return this.cachedCookies = Cookies.empty } - get headers(): Headers.Headers { - return Headers.fromInput(this.source.headers) - } - get remoteAddress(): Option.Option { return Option.none() } diff --git a/packages/platform-node/src/internal/httpIncomingMessage.ts b/packages/platform-node/src/internal/httpIncomingMessage.ts index e89a78032aa..770e64fa153 100644 --- a/packages/platform-node/src/internal/httpIncomingMessage.ts +++ b/packages/platform-node/src/internal/httpIncomingMessage.ts @@ -14,18 +14,17 @@ export abstract class HttpIncomingMessageImpl extends Inspectable.Class implements IncomingMessage.HttpIncomingMessage { readonly [IncomingMessage.TypeId]: IncomingMessage.TypeId + readonly headers: Headers.Headers constructor( readonly source: Http.IncomingMessage, readonly onError: (error: unknown) => E, - readonly remoteAddressOverride?: string + readonly remoteAddressOverride?: string, + readonly headersOverride?: Headers.Headers ) { super() this[IncomingMessage.TypeId] = IncomingMessage.TypeId - } - - get headers() { - return Headers.fromInput(this.source.headers as any) + this.headers = headersOverride ?? Headers.fromInput(this.source.headers as any) } get remoteAddress() { diff --git a/packages/platform-node/src/internal/httpServer.ts b/packages/platform-node/src/internal/httpServer.ts index d9d63e30d86..a8249152919 100644 --- a/packages/platform-node/src/internal/httpServer.ts +++ b/packages/platform-node/src/internal/httpServer.ts @@ -213,15 +213,20 @@ class ServerRequestImpl extends HttpIncomingMessageImpl impl readonly response: Http.ServerResponse | LazyArg, private upgradeEffect?: Effect.Effect, readonly url = source.url!, - private headersOverride?: Headers.Headers, + headersOverride?: Headers.Headers, remoteAddressOverride?: string ) { - super(source, (cause) => - new Error.RequestError({ - request: this, - reason: "Decode", - cause - }), remoteAddressOverride) + super( + source, + (cause) => + new Error.RequestError({ + request: this, + reason: "Decode", + cause + }), + remoteAddressOverride, + headersOverride + ) this[ServerRequest.TypeId] = ServerRequest.TypeId } @@ -262,11 +267,6 @@ class ServerRequestImpl extends HttpIncomingMessageImpl impl return this.source.method!.toUpperCase() as HttpMethod } - get headers(): Headers.Headers { - this.headersOverride ??= this.source.headers as Headers.Headers - return this.headersOverride - } - private multipartEffect: | Effect.Effect< Multipart.Persisted, diff --git a/packages/platform/src/Headers.ts b/packages/platform/src/Headers.ts index 83c27d96efd..852b66e4f33 100644 --- a/packages/platform/src/Headers.ts +++ b/packages/platform/src/Headers.ts @@ -1,10 +1,8 @@ /** * @since 1.0.0 */ -import { FiberRefs } from "effect" -import * as FiberRef from "effect/FiberRef" +import * as Context from "effect/Context" import { dual, identity } from "effect/Function" -import { globalValue } from "effect/GlobalValue" import * as Inspectable from "effect/Inspectable" import type * as Option from "effect/Option" import * as Predicate from "effect/Predicate" @@ -13,7 +11,6 @@ import * as Redacted from "effect/Redacted" import * as Schema from "effect/Schema" import * as String from "effect/String" import type { Mutable } from "effect/Types" - /** * @since 1.0.0 * @category type ids @@ -44,18 +41,24 @@ export interface Headers extends Inspectable.Redactable { const Proto = Object.assign(Object.create(null), { [HeadersTypeId]: HeadersTypeId, [Inspectable.symbolRedactable]( - this: Headers, - fiberRefs: FiberRefs.FiberRefs + this: Headers ): Record> { - return redact(this, FiberRefs.getOrDefault(fiberRefs, currentRedactedNames)) + return redact(this, redactableContext.get(this)) }, [Inspectable.NodeInspectSymbol]() { return Inspectable.redact(this) } }) -const make = (input: Record.ReadonlyRecord): Mutable => - Object.assign(Object.create(Proto), input) as Headers +const redactableContext = Inspectable.makeRedactableContext((context) => Context.get(context, RedactedNames)) + +const make = (input: Record.ReadonlyRecord, options?: { + readonly redactableContext?: Context.Context | undefined +}): Mutable => { + const self = Object.assign(Object.create(Proto), input) as Headers + redactableContext.register(self, options?.redactableContext, input) + return self +} /** * @since 1.0.0 @@ -89,23 +92,29 @@ export type Input = * @since 1.0.0 * @category constructors */ -export const empty: Headers = Object.create(Proto) +export const empty = (options?: { + readonly redactableContext?: Context.Context | undefined +}): Headers => make({}, options) /** * @since 1.0.0 * @category constructors */ -export const fromInput: (input?: Input) => Headers = (input) => { +export const fromInput = (input?: Input, options?: { + readonly redactableContext?: Context.Context | undefined +}): Headers => { if (input === undefined) { - return empty + return empty(options) + } else if (isHeaders(input)) { + return input } else if (Symbol.iterator in input) { - const out: Record = Object.create(Proto) + const out: Record = make({}, options) for (const [k, v] of input) { out[k.toLowerCase()] = v } return out as Headers } - const out: Record = Object.create(Proto) + const out: Record = make({}, options) for (const [k, v] of Object.entries(input)) { if (Array.isArray(v)) { out[k.toLowerCase()] = v.join(", ") @@ -120,8 +129,13 @@ export const fromInput: (input?: Input) => Headers = (input) => { * @since 1.0.0 * @category constructors */ -export const unsafeFromRecord = (input: Record.ReadonlyRecord): Headers => - Object.setPrototypeOf(input, Proto) as Headers +export const unsafeFromRecord = (input: Record.ReadonlyRecord, options?: { + readonly redactableContext?: Context.Context | undefined +}): Headers => { + const self = Object.setPrototypeOf(input, Proto) + redactableContext.register(self, options?.redactableContext, input) + return self as Headers +} /** * @since 1.0.0 @@ -173,11 +187,14 @@ export const setAll: { } = dual< (headers: Input) => (self: Headers) => Headers, (self: Headers, headers: Input) => Headers ->(2, (self, headers) => - make({ +>(2, (self, headers) => { + const out = make({ ...self, ...fromInput(headers) - })) + }) + redactableContext.register(out, undefined, self) + return out +}) /** * @since 1.0.0 @@ -192,6 +209,7 @@ export const merge: { >(2, (self, headers) => { const out = make(self) Object.assign(out, headers) + redactableContext.register(out, undefined, self) return out }) @@ -257,15 +275,16 @@ export const redact: { /** * @since 1.0.0 - * @category fiber refs + * @category references */ -export const currentRedactedNames: FiberRef.FiberRef> = globalValue( +export class RedactedNames extends Context.Reference()< "@effect/platform/Headers/currentRedactedNames", - () => - FiberRef.unsafeMake>([ - "authorization", - "cookie", - "set-cookie", - "x-api-key" - ]) -) + ReadonlyArray +>("@effect/platform/Headers/currentRedactedNames", { + defaultValue: () => [ + "authorization", + "cookie", + "set-cookie", + "x-api-key" + ] +}) {} diff --git a/packages/platform/src/internal/httpClient.ts b/packages/platform/src/internal/httpClient.ts index 0a62df595bb..e0df4dddf78 100644 --- a/packages/platform/src/internal/httpClient.ts +++ b/packages/platform/src/internal/httpClient.ts @@ -169,7 +169,7 @@ export const make = ( if (query !== "") { span.attribute("url.query", query) } - const redactedHeaderNames = fiber.getFiberRef(Headers.currentRedactedNames) + const redactedHeaderNames = Context.get(fiber.currentContext, Headers.RedactedNames) const redactedHeaders = Headers.redact(request.headers, redactedHeaderNames) for (const name in redactedHeaders) { span.attribute(`http.request.header.${name}`, String(redactedHeaders[name])) diff --git a/packages/platform/src/internal/httpClientRequest.ts b/packages/platform/src/internal/httpClientRequest.ts index 1cf89476c73..a7643480c83 100644 --- a/packages/platform/src/internal/httpClientRequest.ts +++ b/packages/platform/src/internal/httpClientRequest.ts @@ -66,7 +66,7 @@ export const empty: ClientRequest.HttpClientRequest = makeInternal( "", UrlParams.empty, Option.none(), - Headers.empty, + Headers.empty(), internalBody.empty ) diff --git a/packages/platform/src/internal/httpClientResponse.ts b/packages/platform/src/internal/httpClientResponse.ts index d5a902a778b..5ba35c57e51 100644 --- a/packages/platform/src/internal/httpClientResponse.ts +++ b/packages/platform/src/internal/httpClientResponse.ts @@ -27,6 +27,7 @@ export const fromWeb = ( class ClientResponseImpl extends Inspectable.Class implements ClientResponse.HttpClientResponse { readonly [IncomingMessage.TypeId]: IncomingMessage.TypeId readonly [TypeId]: ClientResponse.TypeId + readonly headers: Headers.Headers constructor( readonly request: ClientRequest.HttpClientRequest, @@ -35,6 +36,7 @@ class ClientResponseImpl extends Inspectable.Class implements ClientResponse.Htt super() this[IncomingMessage.TypeId] = IncomingMessage.TypeId this[TypeId] = TypeId + this.headers = Headers.fromInput(this.source.headers) } toJSON(): unknown { @@ -49,10 +51,6 @@ class ClientResponseImpl extends Inspectable.Class implements ClientResponse.Htt return this.source.status } - get headers(): Headers.Headers { - return Headers.fromInput(this.source.headers) - } - cachedCookies?: Cookies.Cookies get cookies(): Cookies.Cookies { if (this.cachedCookies) { diff --git a/packages/platform/src/internal/httpMiddleware.ts b/packages/platform/src/internal/httpMiddleware.ts index aa1ff93f02e..ce3afc37c95 100644 --- a/packages/platform/src/internal/httpMiddleware.ts +++ b/packages/platform/src/internal/httpMiddleware.ts @@ -119,7 +119,7 @@ export const tracer = make((httpApp) => url.username = "REDACTED" url.password = "REDACTED" } - const redactedHeaderNames = fiber.getFiberRef(Headers.currentRedactedNames) + const redactedHeaderNames = Context.get(fiber.currentContext, Headers.RedactedNames) const redactedHeaders = Headers.redact(request.headers, redactedHeaderNames) return Effect.useSpan( `http.server ${request.method}`, diff --git a/packages/platform/src/internal/httpPlatform.ts b/packages/platform/src/internal/httpPlatform.ts index 3194524be0e..2022efd998b 100644 --- a/packages/platform/src/internal/httpPlatform.ts +++ b/packages/platform/src/internal/httpPlatform.ts @@ -50,7 +50,7 @@ export const make = (impl: { const start = Number(options?.offset ?? 0) const end = options?.bytesToRead !== undefined ? start + Number(options.bytesToRead) : undefined const headers = Headers.set( - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), "etag", Etag.toString(etag) ) @@ -73,7 +73,7 @@ export const make = (impl: { fileWebResponse(file, options) { return Effect.map(etagGen.fromFileWeb(file), (etag) => { const headers = Headers.merge( - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), Headers.unsafeFromRecord({ etag: Etag.toString(etag), "last-modified": new Date(file.lastModified).toUTCString() diff --git a/packages/platform/src/internal/httpServerRequest.ts b/packages/platform/src/internal/httpServerRequest.ts index dc9d8eade58..00e184c360b 100644 --- a/packages/platform/src/internal/httpServerRequest.ts +++ b/packages/platform/src/internal/httpServerRequest.ts @@ -188,6 +188,7 @@ const removeHost = (url: string) => { class ServerRequestImpl extends Inspectable.Class implements ServerRequest.HttpServerRequest { readonly [TypeId]: ServerRequest.TypeId readonly [IncomingMessage.TypeId]: IncomingMessage.TypeId + readonly headers: Headers.Headers constructor( readonly source: Request, readonly url: string, @@ -197,6 +198,7 @@ class ServerRequestImpl extends Inspectable.Class implements ServerRequest.HttpS super() this[TypeId] = TypeId this[IncomingMessage.TypeId] = IncomingMessage.TypeId + this.headers = headersOverride ?? Headers.fromInput(source.headers) } toJSON(): unknown { return IncomingMessage.inspect(this, { @@ -228,10 +230,6 @@ class ServerRequestImpl extends Inspectable.Class implements ServerRequest.HttpS get remoteAddress(): Option.Option { return this.remoteAddressOverride ? Option.some(this.remoteAddressOverride) : Option.none() } - get headers(): Headers.Headers { - this.headersOverride ??= Headers.fromInput(this.source.headers) - return this.headersOverride - } private cachedCookies: ReadonlyRecord | undefined get cookies() { diff --git a/packages/platform/src/internal/httpServerResponse.ts b/packages/platform/src/internal/httpServerResponse.ts index a3415857009..69661d06e7c 100644 --- a/packages/platform/src/internal/httpServerResponse.ts +++ b/packages/platform/src/internal/httpServerResponse.ts @@ -91,7 +91,7 @@ export const empty = (options?: ServerResponse.Options.WithContent | undefined): new ServerResponseImpl( options?.status ?? 204, options?.statusText, - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), options?.cookies ?? Cookies.empty, internalBody.empty ) @@ -121,7 +121,7 @@ export const uint8Array = ( body: Uint8Array, options?: ServerResponse.Options.WithContentType ): ServerResponse.HttpServerResponse => { - const headers = options?.headers ? Headers.fromInput(options.headers) : Headers.empty + const headers = options?.headers ? Headers.fromInput(options.headers) : Headers.empty() return new ServerResponseImpl( options?.status ?? 200, options?.statusText, @@ -136,7 +136,7 @@ export const text = ( body: string, options?: ServerResponse.Options.WithContentType ): ServerResponse.HttpServerResponse => { - const headers = options?.headers ? Headers.fromInput(options.headers) : Headers.empty + const headers = options?.headers ? Headers.fromInput(options.headers) : Headers.empty() return new ServerResponseImpl( options?.status ?? 200, options?.statusText, @@ -201,7 +201,7 @@ export const json = ( new ServerResponseImpl( options?.status ?? 200, options?.statusText, - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), options?.cookies ?? Cookies.empty, body )) @@ -214,7 +214,7 @@ export const unsafeJson = ( new ServerResponseImpl( options?.status ?? 200, options?.statusText, - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), options?.cookies ?? Cookies.empty, internalBody.unsafeJson(body) ) @@ -233,7 +233,7 @@ export const schemaJson = ( new ServerResponseImpl( options?.status ?? 200, options?.statusText, - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), options?.cookies ?? Cookies.empty, body )) @@ -269,7 +269,7 @@ export const urlParams = ( new ServerResponseImpl( options?.status ?? 200, options?.statusText, - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), options?.cookies ?? Cookies.empty, internalBody.text(UrlParams.toString(UrlParams.fromInput(body)), "application/x-www-form-urlencoded") ) @@ -279,7 +279,7 @@ export const raw = (body: unknown, options?: ServerResponse.Options | undefined) new ServerResponseImpl( options?.status ?? 200, options?.statusText, - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), options?.cookies ?? Cookies.empty, internalBody.raw(body) ) @@ -292,7 +292,7 @@ export const formData = ( new ServerResponseImpl( options?.status ?? 200, options?.statusText, - options?.headers ? Headers.fromInput(options.headers) : Headers.empty, + options?.headers ? Headers.fromInput(options.headers) : Headers.empty(), options?.cookies ?? Cookies.empty, internalBody.formData(body) ) @@ -302,7 +302,7 @@ export const stream = ( body: Stream.Stream, options?: ServerResponse.Options | undefined ): ServerResponse.HttpServerResponse => { - const headers = options?.headers ? Headers.fromInput(options.headers) : Headers.empty + const headers = options?.headers ? Headers.fromInput(options.headers) : Headers.empty() return new ServerResponseImpl( options?.status ?? 200, options?.statusText, diff --git a/packages/platform/test/Headers.test.ts b/packages/platform/test/Headers.test.ts index aea0a4745e3..b0ae2b72b1d 100644 --- a/packages/platform/test/Headers.test.ts +++ b/packages/platform/test/Headers.test.ts @@ -1,29 +1,26 @@ import * as Headers from "@effect/platform/Headers" import { assert, describe, it } from "@effect/vitest" -import { Console, Effect, FiberId, FiberRef, FiberRefs, HashSet, Inspectable, Logger } from "effect" +import { Effect, FiberRef, HashSet, Inspectable, Logger } from "effect" import * as Redacted from "effect/Redacted" describe("Headers", () => { describe("Redactable", () => { it("one key", async () => { - const headers = Headers.fromInput({ + const rawHeaders = { "Content-Type": "application/json", "Authorization": "Bearer some-token", "X-Api-Key": "some-key" + } + assert.deepEqual(JSON.parse(Inspectable.toStringUnknown(Headers.fromInput(rawHeaders))), { + "content-type": "application/json", + "authorization": "", + "x-api-key": "" }) - const fiberRefs = FiberRefs.unsafeMake( - new Map([ - [ - Headers.currentRedactedNames, - [[FiberId.none, ["Authorization"]] as const] - ] as const - ]) + const r = Effect.sync(() => Inspectable.toStringUnknown(Headers.fromInput(rawHeaders))).pipe( + Effect.provideService(Headers.RedactedNames, ["Authorization"]), + Effect.runSync ) - const r = Effect.gen(function*() { - yield* Effect.setFiberRefs(fiberRefs) - return Inspectable.toStringUnknown(headers) - }).pipe(Effect.runSync) const redacted = JSON.parse(r) as any assert.deepEqual(redacted, { @@ -39,25 +36,12 @@ describe("Headers", () => { "Authorization": "Bearer some-token", "X-Api-Key": "some-key" }) - - const fiberRefs = FiberRefs.unsafeMake( - new Map([ - [ - Headers.currentRedactedNames, - [[FiberId.none, ["Authorization"]] as const] - ] as const - ]) - ) - const r = Effect.gen(function*() { - yield* Effect.setFiberRefs(fiberRefs) - return Inspectable.toStringUnknown({ headers }) - }).pipe(Effect.runSync) - const redacted = JSON.parse(r) as { headers: unknown } - - assert.deepEqual(redacted.headers, { - "content-type": "application/json", - "authorization": "", - "x-api-key": "some-key" + assert.deepEqual(JSON.parse(Inspectable.toStringUnknown({ headers })), { + headers: { + "content-type": "application/json", + "authorization": "", + "x-api-key": "" + } }) }) @@ -78,7 +62,6 @@ describe("Headers", () => { yield* Effect.log(headers).pipe( Effect.annotateLogs({ headers }) ) - yield* Console.log(headers) assert.include(messages[0], "application/json") assert.notInclude(messages[0], "some-token") assert.notInclude(messages[0], "some-key") diff --git a/packages/platform/test/HttpClient.test.ts b/packages/platform/test/HttpClient.test.ts index d6993f9fd22..50fdf6983a2 100644 --- a/packages/platform/test/HttpClient.test.ts +++ b/packages/platform/test/HttpClient.test.ts @@ -1,14 +1,13 @@ import { Cookies, FetchHttpClient, - Headers, HttpClient, HttpClientRequest, HttpClientResponse, UrlParams } from "@effect/platform" import { assert, describe, expect, it } from "@effect/vitest" -import { Either, FiberId, FiberRefs, Inspectable, Ref, Struct } from "effect" +import { Either, Inspectable, Ref, Struct } from "effect" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" @@ -200,19 +199,7 @@ describe("HttpClient", () => { }) ) - const fiberRefs = FiberRefs.unsafeMake( - new Map([ - [ - Headers.currentRedactedNames, - [[FiberId.none, ["Authorization"]] as const] - ] as const - ]) - ) - const r = Effect.gen(function*() { - yield* Effect.setFiberRefs(fiberRefs) - return Inspectable.toStringUnknown(request) - }).pipe(Effect.runSync) - const redacted = JSON.parse(r) + const redacted = JSON.parse(Inspectable.toStringUnknown(request)) assert.deepStrictEqual(redacted, { _id: "@effect/platform/HttpClientRequest", diff --git a/packages/rpc/src/Rpc.ts b/packages/rpc/src/Rpc.ts index 8e096e793b5..545b5dfa698 100644 --- a/packages/rpc/src/Rpc.ts +++ b/packages/rpc/src/Rpc.ts @@ -303,7 +303,7 @@ export const RequestSchema = ( */ export const currentHeaders: FiberRef.FiberRef = globalValue( "@effect/rpc/Rpc/currentHeaders", - () => FiberRef.unsafeMake(Headers.empty) + () => FiberRef.unsafeMake(Headers.empty()) ) /** @@ -313,10 +313,15 @@ export const currentHeaders: FiberRef.FiberRef = globalValue( export const annotateHeaders: { (headers: Headers.Input): (self: Effect.Effect) => Effect.Effect (self: Effect.Effect, headers: Headers.Input): Effect.Effect -} = dual(2, (self, headers) => { - const resolved = Headers.fromInput(headers) - return Effect.locallyWith(self, currentHeaders, (prev) => ({ ...prev, ...resolved })) -}) +} = dual( + 2, + (self, headers) => + Effect.locallyWith( + self, + currentHeaders, + (prev) => ({ ...prev, ...Headers.fromInput(headers) }) + ) +) /** * @since 1.0.0 diff --git a/packages/rpc/src/RpcResolver.ts b/packages/rpc/src/RpcResolver.ts index fabb0cf829c..111840c4cac 100644 --- a/packages/rpc/src/RpcResolver.ts +++ b/packages/rpc/src/RpcResolver.ts @@ -121,17 +121,15 @@ export const annotateHeaders: { } = dual(2, ( self: RequestResolver.RequestResolver, R>, headers: Headers.Input -): RequestResolver.RequestResolver, R> => { - const resolved = Headers.fromInput(headers) - return RequestResolver.makeWithEntry((requests) => { +): RequestResolver.RequestResolver, R> => + RequestResolver.makeWithEntry((requests) => { requests.forEach((entries) => entries.forEach((entry) => { - ;(entry.request as any).headers = Headers.merge(entry.request.headers, resolved) + ;(entry.request as any).headers = Headers.merge(entry.request.headers, Headers.fromInput(headers)) }) ) return self.runAll(requests) - }) -}) + })) /** * @since 1.0.0 From 96616ad3091a29c84924066e6ea4161dc61b6e38 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 20 Dec 2024 10:41:27 +1300 Subject: [PATCH 15/15] fix circular dep --- packages/effect/src/Inspectable.ts | 78 +++++-------------- packages/effect/src/internal/context.ts | 2 +- packages/effect/src/internal/either.ts | 2 +- packages/effect/src/internal/inspectable.ts | 83 +++++++++++++++++++++ packages/effect/src/internal/option.ts | 2 +- 5 files changed, 104 insertions(+), 63 deletions(-) create mode 100644 packages/effect/src/internal/inspectable.ts diff --git a/packages/effect/src/Inspectable.ts b/packages/effect/src/Inspectable.ts index 4467c6ebaf3..567eafddb8b 100644 --- a/packages/effect/src/Inspectable.ts +++ b/packages/effect/src/Inspectable.ts @@ -4,13 +4,13 @@ import * as Context from "./Context.js" import type { RuntimeFiber } from "./Fiber.js" import { globalValue } from "./GlobalValue.js" -import { hasProperty, isFunction } from "./Predicate.js" +import * as internal from "./internal/inspectable.js" /** * @since 2.0.0 * @category symbols */ -export const NodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom") +export const NodeInspectSymbol: unique symbol = internal.NodeInspectSymbol /** * @since 2.0.0 @@ -31,41 +31,17 @@ export interface Inspectable { /** * @since 2.0.0 */ -export const toJSON = (x: unknown): unknown => { - try { - if ( - hasProperty(x, "toJSON") && isFunction(x["toJSON"]) && - x["toJSON"].length === 0 - ) { - return x.toJSON() - } else if (Array.isArray(x)) { - return x.map(toJSON) - } - } catch (_) { - return {} - } - return redact(x) -} +export const toJSON: (x: unknown) => unknown = internal.toJSON /** * @since 2.0.0 */ -export const format = (x: unknown): string => JSON.stringify(x, null, 2) +export const format: (x: unknown) => string = internal.format /** * @since 2.0.0 */ -export const BaseProto: Inspectable = { - toJSON() { - return toJSON(this) - }, - [NodeInspectSymbol]() { - return this.toJSON() - }, - toString() { - return format(this.toJSON()) - } -} +export const BaseProto: Inspectable = internal.BaseProto /** * @since 2.0.0 @@ -92,41 +68,26 @@ export abstract class Class { /** * @since 2.0.0 */ -export const toStringUnknown = (u: unknown, whitespace: number | string | undefined = 2): string => { - if (typeof u === "string") { - return u - } - try { - return typeof u === "object" ? stringifyCircular(u, whitespace) : String(u) - } catch (_) { - return String(u) - } -} +export const toStringUnknown: (u: unknown, whitespace?: number | string | undefined) => string = + internal.toStringUnknown /** * @since 2.0.0 */ -export const stringifyCircular = (obj: unknown, whitespace?: number | string | undefined): string => { - let cache: Array = [] - const retVal = JSON.stringify( - obj, - (_key, value) => - typeof value === "object" && value !== null - ? cache.includes(value) - ? undefined // circular reference - : cache.push(value) && (isRedactable(value) ? redact(value) : value) - : value, - whitespace - ) - ;(cache as any) = undefined - return retVal -} +export const stringifyCircular: (obj: unknown, whitespace?: number | string | undefined) => string = + internal.stringifyCircular /** * @since 3.10.0 * @category redactable */ -export const symbolRedactable: unique symbol = Symbol.for("effect/Inspectable/Redactable") +export const symbolRedactable: unique symbol = internal.symbolRedactable + +/** + * @since 3.12.0 + * @category redactable + */ +export type symbolRedactable = typeof symbolRedactable /** * @since 3.10.0 @@ -140,16 +101,13 @@ export interface Redactable { * @since 3.10.0 * @category redactable */ -export const isRedactable = (u: unknown): u is Redactable => - typeof u === "object" && u !== null && symbolRedactable in u +export const isRedactable: (u: unknown) => u is Redactable = internal.isRedactable /** * @since 3.10.0 * @category redactable */ -export const redact = (u: unknown): unknown => { - return isRedactable(u) ? u[symbolRedactable]() : u -} +export const redact: (u: unknown) => unknown = internal.redact const redactableContext = globalValue("effect/Inspectable/redactableContext", () => new WeakMap()) diff --git a/packages/effect/src/internal/context.ts b/packages/effect/src/internal/context.ts index 20e0badd047..c568ea42a8f 100644 --- a/packages/effect/src/internal/context.ts +++ b/packages/effect/src/internal/context.ts @@ -4,12 +4,12 @@ import type { LazyArg } from "../Function.js" import { dual } from "../Function.js" import { globalValue } from "../GlobalValue.js" import * as Hash from "../Hash.js" -import { format, NodeInspectSymbol, toJSON } from "../Inspectable.js" import type * as O from "../Option.js" import { pipeArguments } from "../Pipeable.js" import { hasProperty } from "../Predicate.js" import type * as STM from "../STM.js" import { EffectPrototype, effectVariance } from "./effectable.js" +import { format, NodeInspectSymbol, toJSON } from "./inspectable.js" import * as option from "./option.js" /** @internal */ diff --git a/packages/effect/src/internal/either.ts b/packages/effect/src/internal/either.ts index 6ef2f5f55f5..92439582c46 100644 --- a/packages/effect/src/internal/either.ts +++ b/packages/effect/src/internal/either.ts @@ -6,10 +6,10 @@ import type * as Either from "../Either.js" import * as Equal from "../Equal.js" import { dual } from "../Function.js" import * as Hash from "../Hash.js" -import { format, NodeInspectSymbol, toJSON } from "../Inspectable.js" import type { Option } from "../Option.js" import { hasProperty } from "../Predicate.js" import { EffectPrototype } from "./effectable.js" +import { format, NodeInspectSymbol, toJSON } from "./inspectable.js" import * as option from "./option.js" /** diff --git a/packages/effect/src/internal/inspectable.ts b/packages/effect/src/internal/inspectable.ts new file mode 100644 index 00000000000..0367f117f0b --- /dev/null +++ b/packages/effect/src/internal/inspectable.ts @@ -0,0 +1,83 @@ +import type * as Api from "../Inspectable.js" +import { hasProperty, isFunction } from "../Predicate.js" + +/** @internal */ +export const NodeInspectSymbol: Api.NodeInspectSymbol = Symbol.for( + "nodejs.util.inspect.custom" +) as Api.NodeInspectSymbol + +/** @internal */ +export const toJSON = (x: unknown): unknown => { + try { + if ( + hasProperty(x, "toJSON") && isFunction(x["toJSON"]) && + x["toJSON"].length === 0 + ) { + return x.toJSON() + } else if (Array.isArray(x)) { + return x.map(toJSON) + } + } catch (_) { + return {} + } + return redact(x) +} + +/** @internal */ +export const format = (x: unknown): string => JSON.stringify(x, null, 2) + +/** @internal */ +export const BaseProto: Api.Inspectable = { + toJSON() { + return toJSON(this) + }, + [NodeInspectSymbol]() { + return this.toJSON() + }, + toString() { + return format(this.toJSON()) + } +} + +/** @internal */ +export const toStringUnknown = (u: unknown, whitespace: number | string | undefined = 2): string => { + if (typeof u === "string") { + return u + } + try { + return typeof u === "object" ? stringifyCircular(u, whitespace) : String(u) + } catch (_) { + return String(u) + } +} + +/** @internal */ +export const stringifyCircular = (obj: unknown, whitespace?: number | string | undefined): string => { + let cache: Array = [] + const retVal = JSON.stringify( + obj, + (_key, value) => + typeof value === "object" && value !== null + ? cache.includes(value) + ? undefined // circular reference + : cache.push(value) && (isRedactable(value) ? redact(value) : value) + : value, + whitespace + ) + ;(cache as any) = undefined + return retVal +} + +/** @internal */ +export const symbolRedactable: Api.symbolRedactable = Symbol.for( + "effect/Inspectable/Redactable" +) as Api.symbolRedactable + +/** @internal */ +export const isRedactable = (u: unknown): u is Api.Redactable => + typeof u === "object" && u !== null && symbolRedactable in u + +/** @internal */ +export const redact = (u: unknown): unknown => { + return isRedactable(u) ? u[symbolRedactable]() : u +} diff --git a/packages/effect/src/internal/option.ts b/packages/effect/src/internal/option.ts index 7d97adee2cd..258ec7a1ca5 100644 --- a/packages/effect/src/internal/option.ts +++ b/packages/effect/src/internal/option.ts @@ -4,10 +4,10 @@ import * as Equal from "../Equal.js" import * as Hash from "../Hash.js" -import { format, NodeInspectSymbol, toJSON } from "../Inspectable.js" import type * as Option from "../Option.js" import { hasProperty } from "../Predicate.js" import { EffectPrototype } from "./effectable.js" +import { format, NodeInspectSymbol, toJSON } from "./inspectable.js" const TypeId: Option.TypeId = Symbol.for("effect/Option") as Option.TypeId