344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import { assert, describe, expect, it } from "@effect/vitest"
|
|
import { Array, Context, Data, Equal, Fiber, Hash } from "effect"
|
|
import * as Cause from "effect/Cause"
|
|
import * as Effect from "effect/Effect"
|
|
import * as Exit from "effect/Exit"
|
|
import { flow, pipe } from "effect/Function"
|
|
import * as Request from "effect/Request"
|
|
import * as Resolver from "effect/RequestResolver"
|
|
|
|
class Counter extends Context.Service<Counter, { count: number }>()("Counter") {}
|
|
class Requests extends Context.Service<Requests, { count: number }>()("Requests") {}
|
|
class Interrupts extends Context.Reference("Interrupts", {
|
|
defaultValue: () => ({ interrupts: 0 })
|
|
}) {}
|
|
class RequestService extends Context.Reference("RequestService", {
|
|
defaultValue: () => ({ value: "default" })
|
|
}) {}
|
|
const delay = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
|
Effect.andThen(
|
|
Effect.promise(() => new Promise((r) => setTimeout(() => r(0), 0))),
|
|
self
|
|
)
|
|
|
|
const userIds: ReadonlyArray<number> = Array.range(1, 26)
|
|
|
|
const userNames: ReadonlyMap<number, string> = new Map(
|
|
Array.zipWith(
|
|
userIds,
|
|
Array.map(Array.range(97, 122), (a) => String.fromCharCode(a)),
|
|
(a, b) => [a, b] as const
|
|
)
|
|
)
|
|
|
|
type UserRequest = GetAllIds | GetNameById
|
|
|
|
interface GetAllIds extends Request.Request<ReadonlyArray<number>> {
|
|
readonly _tag: "GetAllIds"
|
|
}
|
|
const GetAllIds = Request.tagged<GetAllIds>("GetAllIds")
|
|
|
|
interface GetRequestService extends Request.Request<string> {
|
|
readonly _tag: "GetRequestService"
|
|
}
|
|
const GetRequestService = Request.tagged<GetRequestService>("GetRequestService")
|
|
|
|
class GetNameById extends Request.TaggedClass("GetNameById")<
|
|
{
|
|
readonly id: number
|
|
},
|
|
string,
|
|
string
|
|
> {}
|
|
|
|
const makeUserResolver = Effect.gen(function*() {
|
|
const counter = yield* Counter
|
|
const requests_ = yield* Requests
|
|
|
|
const resolver = Resolver.make<UserRequest>(Effect.fnUntraced(function*(entries) {
|
|
counter.count++
|
|
requests_.count += entries.length
|
|
for (const entry of entries) {
|
|
yield* delay(processRequest(entry))
|
|
}
|
|
})).pipe(Resolver.batchN(15))
|
|
|
|
const getIds = Effect.request(GetAllIds(), resolver)
|
|
const getNameById = (id: number) => Effect.request(new GetNameById({ id }), resolver)
|
|
const getNameByIdPiped = (id: number) => pipe(new GetNameById({ id }), Effect.request(resolver))
|
|
const getNames = getIds.pipe(
|
|
Effect.flatMap(Effect.forEach(getNameById, { concurrency: "unbounded" })),
|
|
Effect.onInterrupt(() =>
|
|
Effect.tap(Interrupts, (i) => {
|
|
i.interrupts++
|
|
return Effect.void
|
|
})
|
|
)
|
|
)
|
|
|
|
return { getNames, getIds, getNameById, getNameByIdPiped } as const
|
|
})
|
|
|
|
const makeUserResolverTagged = Effect.gen(function*() {
|
|
const counter = yield* Counter
|
|
const requests = yield* Requests
|
|
|
|
const resolver = Resolver.fromEffectTagged<UserRequest>()({
|
|
GetAllIds: Effect.fnUntraced(function*(reqs) {
|
|
counter.count++
|
|
requests.count += reqs.length
|
|
return reqs.map(() => userIds)
|
|
}),
|
|
GetNameById: Effect.fnUntraced(function*(reqs) {
|
|
counter.count++
|
|
requests.count += reqs.length
|
|
|
|
const names: Array<string> = []
|
|
for (let i = 0; i < reqs.length; i++) {
|
|
const req = reqs[i]
|
|
if (!userNames.has(req.request.id)) return yield* Effect.fail("Not Found")
|
|
names.push(userNames.get(req.request.id)!)
|
|
}
|
|
return names
|
|
})
|
|
}).pipe(Resolver.batchN(15))
|
|
|
|
const getIds = Effect.request(GetAllIds(), resolver)
|
|
const getNameById = (id: number) => Effect.request(new GetNameById({ id }), resolver)
|
|
const allNames = getIds.pipe(
|
|
Effect.flatMap(Effect.forEach(getNameById, { concurrency: "unbounded" }))
|
|
)
|
|
|
|
return { allNames, getIds, getNameById } as const
|
|
})
|
|
|
|
const processRequest = (entry: Request.Entry<UserRequest>): Effect.Effect<void> => {
|
|
switch (entry.request._tag) {
|
|
case "GetAllIds": {
|
|
return Request.complete(entry, Exit.succeed(userIds))
|
|
}
|
|
case "GetNameById": {
|
|
if (userNames.has(entry.request.id)) {
|
|
const userName = userNames.get(entry.request.id)!
|
|
return Request.complete(entry, Exit.succeed(userName))
|
|
}
|
|
return Request.completeEffect(entry, Exit.fail("Not Found"))
|
|
}
|
|
}
|
|
}
|
|
|
|
const provideEnv = flow(
|
|
Effect.provideServiceEffect(Counter, Effect.sync(() => ({ count: 0 }))),
|
|
Effect.provideServiceEffect(Requests, Effect.sync(() => ({ count: 0 })))
|
|
)
|
|
|
|
describe.sequential("Request", () => {
|
|
it("compares StructuralProto values when hashes collide", () => {
|
|
class Req extends Request.Class<{ id: string; account: string }, string> {}
|
|
|
|
const a = new Req({ id: "id-8", account: "acct-2811" })
|
|
const b = new Req({ id: "id-14", account: "acct-755" })
|
|
|
|
assert.strictEqual(Hash.hash(a), Hash.hash(b))
|
|
assert.strictEqual(Equal.equals(a, b), false)
|
|
})
|
|
|
|
it.effect(
|
|
"requests are executed correctly",
|
|
Effect.fnUntraced(function*() {
|
|
const { getNames } = yield* makeUserResolver
|
|
const names = yield* getNames
|
|
const counter = yield* Counter
|
|
const requests = yield* Requests
|
|
assert.strictEqual(counter.count, 3)
|
|
assert.strictEqual(requests.count, userIds.length + 1)
|
|
assert.deepStrictEqual(names, userIds.map((id) => userNames.get(id)))
|
|
}, provideEnv)
|
|
)
|
|
|
|
it.effect(
|
|
"requests with dual syntax are executed correctly",
|
|
Effect.fnUntraced(function*() {
|
|
const names = yield* (yield* makeUserResolver).getNames
|
|
const counter = yield* Counter
|
|
const requests = yield* Requests
|
|
assert.strictEqual(counter.count, 3)
|
|
assert.strictEqual(requests.count, userIds.length + 1)
|
|
assert.deepStrictEqual(names, userIds.map((id) => userNames.get(id)))
|
|
}, provideEnv)
|
|
)
|
|
|
|
it.effect(
|
|
"requests are executed correctly with fromEffectTagged",
|
|
Effect.fnUntraced(function*() {
|
|
const { allNames } = yield* makeUserResolverTagged
|
|
const names = yield* allNames
|
|
const count = yield* Counter
|
|
expect(count.count).toEqual(3)
|
|
expect(names.length).toBeGreaterThan(2)
|
|
expect(names).toEqual(userIds.map((id) => userNames.get(id)))
|
|
}, provideEnv)
|
|
)
|
|
|
|
it.effect(
|
|
"requests don't break interruption",
|
|
Effect.fnUntraced(
|
|
function*() {
|
|
const { getNames } = yield* makeUserResolver
|
|
const fiber = yield* Effect.forkChild(getNames)
|
|
yield* Effect.yieldNow
|
|
yield* Fiber.interrupt(fiber)
|
|
const exit = yield* Fiber.await(fiber)
|
|
expect(exit._tag).toEqual("Failure")
|
|
if (exit._tag === "Failure") {
|
|
expect(Cause.hasInterruptsOnly(exit.cause)).toEqual(true)
|
|
}
|
|
expect(yield* Counter).toEqual({ count: 0 })
|
|
expect(yield* Interrupts).toEqual({ interrupts: 1 })
|
|
},
|
|
provideEnv,
|
|
Effect.provideService(Interrupts, { interrupts: 0 })
|
|
)
|
|
)
|
|
|
|
it.effect(
|
|
"requests work with uninterruptible",
|
|
Effect.fnUntraced(
|
|
function*() {
|
|
const { getNames } = yield* makeUserResolver
|
|
const fiber = yield* Effect.forkChild(Effect.uninterruptible(getNames))
|
|
yield* Effect.yieldNow
|
|
yield* Fiber.interrupt(fiber)
|
|
const exit = yield* Fiber.await(fiber)
|
|
expect(exit._tag).toEqual("Failure")
|
|
if (exit._tag === "Failure") {
|
|
expect(Cause.hasInterruptsOnly(exit.cause)).toEqual(true)
|
|
}
|
|
expect(yield* Counter).toEqual({ count: 3 })
|
|
expect(yield* Interrupts).toEqual({ interrupts: 0 })
|
|
},
|
|
provideEnv,
|
|
Effect.provideService(Interrupts, { interrupts: 0 })
|
|
)
|
|
)
|
|
|
|
it.effect(
|
|
"grouped requests can be interrupted before execution",
|
|
Effect.fnUntraced(function*() {
|
|
let resolverExecuted = false
|
|
|
|
const resolver = Resolver.make<GetNameById>(Effect.fnUntraced(function*(entries) {
|
|
resolverExecuted = true
|
|
for (const entry of entries) {
|
|
entry.completeUnsafe(Exit.succeed(userNames.get(entry.request.id)!))
|
|
}
|
|
})).pipe(
|
|
Resolver.grouped(({ request }) => request.id),
|
|
Resolver.setDelayEffect(Effect.never)
|
|
)
|
|
|
|
const fiber = yield* Effect.forkChild(Effect.request(new GetNameById({ id: userIds[0] }), resolver))
|
|
yield* Effect.yieldNow
|
|
yield* Fiber.interrupt(fiber)
|
|
const exit = yield* Fiber.await(fiber)
|
|
|
|
assert.strictEqual(exit._tag, "Failure")
|
|
if (exit._tag === "Failure") {
|
|
assert.strictEqual(Cause.hasInterruptsOnly(exit.cause), true)
|
|
}
|
|
assert.strictEqual(resolverExecuted, false)
|
|
})
|
|
)
|
|
|
|
it.effect(
|
|
"batching preserves individual & identical requests",
|
|
Effect.fnUntraced(function*() {
|
|
const { getNameById } = yield* makeUserResolver
|
|
yield* Effect.all([getNameById(userIds[0]), getNameById(userIds[0])], {
|
|
concurrency: "unbounded",
|
|
discard: true
|
|
})
|
|
const requests = yield* Requests
|
|
const invocations = yield* Counter
|
|
expect(requests.count).toEqual(2)
|
|
expect(invocations.count).toEqual(1)
|
|
}, provideEnv)
|
|
)
|
|
|
|
it.effect(
|
|
"grouped requests + batchN",
|
|
Effect.fnUntraced(function*() {
|
|
let count = 0
|
|
let requestsCount = 0
|
|
|
|
class Key extends Data.Class<{ id: number }> {}
|
|
|
|
const resolver = Resolver.make<GetNameById>(Effect.fnUntraced(function*(entries) {
|
|
count++
|
|
requestsCount += entries.length
|
|
for (const entry of entries) {
|
|
entry.completeUnsafe(Exit.succeed(userNames.get(entry.request.id)!))
|
|
}
|
|
})).pipe(
|
|
Resolver.batchN(5),
|
|
Resolver.grouped(({ request }) => new Key({ id: request.id % 2 }))
|
|
)
|
|
|
|
yield* Effect.forEach(userIds, (id) => Effect.request(new GetNameById({ id }), resolver), {
|
|
concurrency: "unbounded"
|
|
})
|
|
|
|
expect(count).toEqual(6)
|
|
expect(requestsCount).toEqual(26)
|
|
})
|
|
)
|
|
|
|
it.effect(
|
|
"batch fibers use request services for runAll",
|
|
Effect.fnUntraced(function*() {
|
|
const resolver = Resolver.make<GetRequestService>(Effect.fnUntraced(function*(entries) {
|
|
const value = (yield* RequestService).value
|
|
for (const entry of entries) {
|
|
entry.completeUnsafe(Exit.succeed(value))
|
|
}
|
|
})).pipe(Resolver.batchN(1))
|
|
|
|
const value = yield* Effect.request(GetRequestService(), resolver).pipe(
|
|
Effect.provideService(RequestService, { value: "provided" })
|
|
)
|
|
|
|
assert.strictEqual(value, "provided")
|
|
})
|
|
)
|
|
|
|
it.effect(
|
|
"batch fibers use request services for delay effects",
|
|
Effect.fnUntraced(function*() {
|
|
let delayServiceValue = ""
|
|
|
|
const resolver = Resolver.make<GetRequestService>((entries) =>
|
|
Effect.sync(() => {
|
|
for (const entry of entries) {
|
|
entry.completeUnsafe(Exit.succeed("ok"))
|
|
}
|
|
})
|
|
).pipe(
|
|
Resolver.setDelayEffect(
|
|
Effect.andThen(
|
|
Effect.yieldNow,
|
|
Effect.gen(function*() {
|
|
delayServiceValue = (yield* RequestService).value
|
|
})
|
|
)
|
|
)
|
|
)
|
|
|
|
yield* Effect.request(GetRequestService(), resolver).pipe(
|
|
Effect.provideService(RequestService, { value: "provided" })
|
|
)
|
|
|
|
assert.strictEqual(delayServiceValue, "provided")
|
|
})
|
|
)
|
|
})
|