14 Commits

Author SHA1 Message Date
Julien Valverdé
c943d81702 QueryClient.make
All checks were successful
Lint / lint (push) Successful in 12s
2025-03-15 22:27:15 +01:00
Julien Valverdé
c2bc406a5f Fixed query error handler
All checks were successful
Lint / lint (push) Successful in 13s
2025-03-15 06:43:47 +01:00
Julien Valverdé
4e778b6c95 VQueryErrorHandler
All checks were successful
Lint / lint (push) Successful in 12s
2025-03-15 05:12:38 +01:00
Julien Valverdé
0437fa5dcc QueryErrorHandler work
All checks were successful
Lint / lint (push) Successful in 16s
2025-03-15 02:30:37 +01:00
Julien Valverdé
5614b8df38 Fix
All checks were successful
Lint / lint (push) Successful in 13s
2025-03-15 00:52:00 +01:00
Julien Valverdé
70b6c4434e Tests
All checks were successful
Lint / lint (push) Successful in 16s
2025-03-14 22:07:53 +01:00
Julien Valverdé
2e8dfbc988 QueryClient
All checks were successful
Lint / lint (push) Successful in 13s
2025-03-14 22:00:53 +01:00
Julien Valverdé
abc47c4647 Fix
Some checks failed
Lint / lint (push) Failing after 12s
2025-03-14 05:04:49 +01:00
Julien Valverdé
eedd2a7f2a makeTag
Some checks failed
Lint / lint (push) Failing after 12s
2025-03-14 04:57:07 +01:00
Julien Valverdé
f4ab575a8d QueryExtension work
Some checks failed
Lint / lint (push) Failing after 13s
2025-03-14 04:24:56 +01:00
Julien Valverdé
747e2c6056 Done QueryClient
All checks were successful
Lint / lint (push) Successful in 14s
2025-03-14 04:13:14 +01:00
Julien Valverdé
68c68417d8 QueryClient work
All checks were successful
Lint / lint (push) Successful in 12s
2025-03-14 03:56:54 +01:00
Julien Valverdé
ed384a62a8 QueryClient work
Some checks failed
Lint / lint (push) Failing after 15s
2025-03-14 03:26:28 +01:00
Julien Valverdé
3a1748bb39 QueryClient tests
All checks were successful
Lint / lint (push) Successful in 18s
2025-03-13 22:31:50 +01:00
12 changed files with 197 additions and 49 deletions

View File

@@ -0,0 +1,50 @@
import { AlertDialog, Button, Flex, Text } from "@radix-ui/themes"
import { ErrorHandler } from "@reffuse/extension-query"
import { Cause, Chunk, Context, Effect, Match, Option, Stream } from "effect"
import { useState } from "react"
import { AppQueryErrorHandler } from "./query"
import { R } from "./reffuse"
export function VQueryErrorHandler() {
const [failure, setFailure] = useState(Option.none<Cause.Cause<
ErrorHandler.Error<Context.Tag.Service<AppQueryErrorHandler>>
>>())
R.useFork(() => AppQueryErrorHandler.pipe(Effect.flatMap(handler =>
Stream.runForEach(handler.errors, v => Effect.sync(() =>
setFailure(Option.some(v))
))
)), [])
return Option.match(failure, {
onSome: v => (
<AlertDialog.Root open>
<AlertDialog.Content maxWidth="450px">
<AlertDialog.Title>Error</AlertDialog.Title>
<AlertDialog.Description size="2">
{Cause.failures(v).pipe(
Chunk.head,
Option.getOrThrow,
Match.value,
Match.tag("RequestError", () => <Text>HTTP request error</Text>),
Match.tag("ResponseError", () => <Text>HTTP response error</Text>),
Match.exhaustive,
)}
</AlertDialog.Description>
<Flex gap="3" mt="4" justify="end">
<AlertDialog.Action>
<Button variant="solid" color="red" onClick={() => setFailure(Option.none())}>
Ok
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
),
onNone: () => <></>,
})
}

View File

@@ -5,6 +5,7 @@ import { Layer } from "effect"
import { StrictMode } from "react" import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import { ReffuseRuntime } from "reffuse" import { ReffuseRuntime } from "reffuse"
import { AppQueryClientLive, AppQueryErrorHandlerLive } from "./query"
import { GlobalContext } from "./reffuse" import { GlobalContext } from "./reffuse"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
@@ -14,6 +15,8 @@ const layer = Layer.empty.pipe(
Layer.provideMerge(Geolocation.layer), Layer.provideMerge(Geolocation.layer),
Layer.provideMerge(Permissions.layer), Layer.provideMerge(Permissions.layer),
Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(FetchHttpClient.layer),
Layer.provideMerge(AppQueryClientLive),
Layer.provideMerge(AppQueryErrorHandlerLive),
) )
const router = createRouter({ routeTree }) const router = createRouter({ routeTree })

View File

@@ -0,0 +1,10 @@
import { HttpClientError } from "@effect/platform"
import { ErrorHandler, QueryClient } from "@reffuse/extension-query"
export class AppQueryErrorHandler extends ErrorHandler.Tag("AppQueryErrorHandler")<AppQueryErrorHandler,
HttpClientError.HttpClientError
>() {}
export const AppQueryErrorHandlerLive = ErrorHandler.layer(AppQueryErrorHandler)
export const [AppQueryClient, AppQueryClientLive] = QueryClient.make({ ErrorHandler: AppQueryErrorHandler })

View File

@@ -1,4 +1,3 @@
import { HttpClientError } from "@effect/platform"
import { QueryService } from "@reffuse/extension-query" import { QueryService } from "@reffuse/extension-query"
import { ParseResult, Schema } from "effect" import { ParseResult, Schema } from "effect"
@@ -8,5 +7,5 @@ export const Result = Schema.Array(Schema.String)
export class Uuid4Query extends QueryService.Tag("Uuid4Query")<Uuid4Query, export class Uuid4Query extends QueryService.Tag("Uuid4Query")<Uuid4Query,
readonly ["uuid4", number], readonly ["uuid4", number],
typeof Result.Type, typeof Result.Type,
HttpClientError.HttpClientError | ParseResult.ParseError ParseResult.ParseError
>() {} >() {}

View File

@@ -2,7 +2,9 @@ import { HttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { LazyRefExtension } from "@reffuse/extension-lazyref" import { LazyRefExtension } from "@reffuse/extension-lazyref"
import { QueryExtension } from "@reffuse/extension-query" import { QueryExtension } from "@reffuse/extension-query"
import { Context } from "effect"
import { Reffuse, ReffuseContext } from "reffuse" import { Reffuse, ReffuseContext } from "reffuse"
import { AppQueryClient, AppQueryErrorHandler } from "./query"
export const GlobalContext = ReffuseContext.make< export const GlobalContext = ReffuseContext.make<
@@ -10,6 +12,8 @@ export const GlobalContext = ReffuseContext.make<
| Geolocation.Geolocation | Geolocation.Geolocation
| Permissions.Permissions | Permissions.Permissions
| HttpClient.HttpClient | HttpClient.HttpClient
| Context.Tag.Service<typeof AppQueryClient>
| AppQueryErrorHandler
>() >()
export class GlobalReffuse extends Reffuse.Reffuse.pipe( export class GlobalReffuse extends Reffuse.Reffuse.pipe(

View File

@@ -1,3 +1,4 @@
import { VQueryErrorHandler } from "@/QueryErrorHandler"
import { Container, Flex, Theme } from "@radix-ui/themes" import { Container, Flex, Theme } from "@radix-ui/themes"
import { createRootRoute, Link, Outlet } from "@tanstack/react-router" import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/router-devtools" import { TanStackRouterDevtools } from "@tanstack/router-devtools"
@@ -26,6 +27,8 @@ function Root() {
</Container> </Container>
<Outlet /> <Outlet />
<VQueryErrorHandler />
<TanStackRouterDevtools /> <TanStackRouterDevtools />
</Theme> </Theme>
) )

View File

@@ -0,0 +1,33 @@
import { type Cause, type Context, Effect, Layer, Queue, Stream } from "effect"
export interface ErrorHandler<E> {
readonly errors: Stream.Stream<Cause.Cause<E>>
readonly handle: <A, SelfE, R>(self: Effect.Effect<A, SelfE, R>) => Effect.Effect<A, Exclude<SelfE, E>, R>
}
export type Error<T> = T extends ErrorHandler<infer E> ? E : never
export const Tag = <const Id extends string>(id: Id) => <
Self, E = never,
>() => Effect.Tag(id)<Self, ErrorHandler<E>>()
export const layer = <Self, Id extends string, E>(
tag: Context.TagClass<Self, Id, ErrorHandler<E>>
): Layer.Layer<Self> => Layer.effect(tag, Effect.gen(function*() {
const queue = yield* Queue.unbounded<Cause.Cause<E>>()
const errors = Stream.fromQueue(queue)
const handle = <A, SelfE, R>(
self: Effect.Effect<A, SelfE, R>
) => Effect.tapErrorCause(self, cause =>
Queue.offer(queue, cause as Cause.Cause<E>)
) as Effect.Effect<A, Exclude<SelfE, E>, R>
return { errors, handle }
}))
export class DefaultErrorHandler extends Tag("@reffuse/extension-query/DefaultErrorHandler")<DefaultErrorHandler>() {}
export const DefaultErrorHandlerLive = layer(DefaultErrorHandler)

View File

@@ -1,20 +0,0 @@
import { type Context, Effect, Layer, Queue, Stream } from "effect"
export interface FailureHandler<E> {
readonly failures: Stream.Stream<E>
readonly queue: Queue.Queue<E>
}
export const Tag = <const Id extends string>(id: Id) => <
Self, E = never,
>() => Effect.Tag(id)<Self, FailureHandler<E>>()
export const layer = <Self, Id extends string, E>(
tag: Context.TagClass<Self, Id, FailureHandler<E>>
): Layer.Layer<Self> => Layer.effect(tag, Queue.unbounded<E>().pipe(
Effect.map(queue => ({
failures: Stream.fromQueue(queue),
queue,
}))
))

View File

@@ -0,0 +1,47 @@
import { Context, Effect, Layer } from "effect"
import * as ErrorHandler from "./ErrorHandler.js"
export interface QueryClient<EH, HandledE> {
readonly ErrorHandler: Context.Tag<EH, ErrorHandler.ErrorHandler<HandledE>>
}
export type Tag<EH, HandledE> = Context.Tag<QueryClient<EH, HandledE>, QueryClient<EH, HandledE>>
export const makeTag = <EH = never, HandledE = never>(): Tag<EH, HandledE> => Context.GenericTag("@reffuse/extension-query/QueryClient")
export interface MakeProps<EH, HandledE> {
readonly ErrorHandler?: Context.Tag<EH, ErrorHandler.ErrorHandler<HandledE>>
}
export type MakeResult<EH, HandledE> = [
tag: Tag<EH, HandledE>,
layer: Layer.Layer<
| QueryClient<EH, HandledE>
| (EH extends ErrorHandler.DefaultErrorHandler
? ErrorHandler.DefaultErrorHandler
: never)
>,
]
export const make = <
EH = ErrorHandler.DefaultErrorHandler,
HandledE = never,
>(
props?: MakeProps<EH, HandledE>
): MakeResult<EH, HandledE> => [
makeTag(),
Layer.empty.pipe(
Layer.provideMerge(
Layer.effect(makeTag<EH, HandledE>(), Effect.succeed({
ErrorHandler: (props?.ErrorHandler ?? ErrorHandler.DefaultErrorHandler) as Context.Tag<EH, ErrorHandler.ErrorHandler<HandledE>>
}))
),
Layer.provideMerge((props?.ErrorHandler
? Layer.empty
: ErrorHandler.DefaultErrorHandlerLive
) as Layer.Layer<ErrorHandler.DefaultErrorHandler>),
),
]

View File

@@ -2,17 +2,11 @@ import type * as AsyncData from "@typed/async-data"
import { type Cause, type Context, Effect, type Fiber, Layer, type Option, type Stream, type SubscriptionRef } from "effect" import { type Cause, type Context, Effect, type Fiber, Layer, type Option, type Stream, type SubscriptionRef } from "effect"
import * as React from "react" import * as React from "react"
import { ReffuseExtension, type ReffuseHelpers } from "reffuse" import { ReffuseExtension, type ReffuseHelpers } from "reffuse"
import * as QueryClient from "./QueryClient.js"
import * as QueryRunner from "./QueryRunner.js" import * as QueryRunner from "./QueryRunner.js"
import type * as QueryService from "./QueryService.js" import type * as QueryService from "./QueryService.js"
export interface QueryExtension<HandlerE> {
useQuery<K extends readonly unknown[], A, E, R>(
this: ReffuseHelpers.ReffuseHelpers<R>,
props: UseQueryProps<K, A, E, R>,
): UseQueryResult<K, A, Exclude<E, HandlerE>>
}
export interface UseQueryProps<K extends readonly unknown[], A, E, R> { export interface UseQueryProps<K extends readonly unknown[], A, E, R> {
readonly key: Stream.Stream<K> readonly key: Stream.Stream<K>
readonly query: (key: K) => Effect.Effect<A, E, R> readonly query: (key: K) => Effect.Effect<A, E, R>
@@ -31,11 +25,20 @@ export interface UseQueryResult<K extends readonly unknown[], A, E> {
export const QueryExtension = ReffuseExtension.make(() => ({ export const QueryExtension = ReffuseExtension.make(() => ({
useQuery<K extends readonly unknown[], A, E, R>( useQuery<
this: ReffuseHelpers.ReffuseHelpers<R>, EH,
props: UseQueryProps<K, A, E, R>, QK extends readonly unknown[],
): UseQueryResult<K, A, E> { QA,
QE,
HandledE,
QR extends R,
R,
>(
this: ReffuseHelpers.ReffuseHelpers<R | QueryClient.QueryClient<EH, HandledE> | EH>,
props: UseQueryProps<QK, QA, QE, QR>,
): UseQueryResult<QK, QA, Exclude<QE, HandledE>> {
const runner = this.useMemo(() => QueryRunner.make({ const runner = this.useMemo(() => QueryRunner.make({
QueryClient: QueryClient.makeTag<EH, HandledE>(),
key: props.key, key: props.key,
query: props.query, query: props.query,
}), [props.key]) }), [props.key])

View File

@@ -1,10 +1,11 @@
import { BrowserStream } from "@effect/platform-browser" import { BrowserStream } from "@effect/platform-browser"
import * as AsyncData from "@typed/async-data" import * as AsyncData from "@typed/async-data"
import { type Cause, Effect, Fiber, identity, Option, Ref, type Scope, Stream, SubscriptionRef } from "effect" import { type Cause, type Context, Effect, Fiber, identity, Option, Ref, type Scope, Stream, SubscriptionRef } from "effect"
import type * as QueryClient from "./QueryClient.js"
export interface QueryRunner<K extends readonly unknown[], A, E, R> { export interface QueryRunner<K extends readonly unknown[], A, E, R> {
readonly query: (key: K) => Effect.Effect<A, E, R> readonly context: Context.Context<R>
readonly latestKeyRef: SubscriptionRef.SubscriptionRef<Option.Option<K>> readonly latestKeyRef: SubscriptionRef.SubscriptionRef<Option.Option<K>>
readonly stateRef: SubscriptionRef.SubscriptionRef<AsyncData.AsyncData<A, E>> readonly stateRef: SubscriptionRef.SubscriptionRef<AsyncData.AsyncData<A, E>>
@@ -19,18 +20,27 @@ export interface QueryRunner<K extends readonly unknown[], A, E, R> {
} }
export interface MakeProps<K extends readonly unknown[], A, E, R> { export interface MakeProps<EH, K extends readonly unknown[], A, E, HandledE, R> {
readonly QueryClient: QueryClient.Tag<EH, HandledE>
readonly key: Stream.Stream<K> readonly key: Stream.Stream<K>
readonly query: (key: K) => Effect.Effect<A, E, R> readonly query: (key: K) => Effect.Effect<A, E, R>
} }
export const make = <K extends readonly unknown[], A, E, R>( export const make = <EH, K extends readonly unknown[], A, E, HandledE, R>(
{ key, query }: MakeProps<K, A, E, R> {
): Effect.Effect<QueryRunner<K, A, E, R>, never, R> => Effect.gen(function*() { QueryClient,
const context = yield* Effect.context<R>() key,
query,
}: MakeProps<EH, K, A, E, HandledE, R>
): Effect.Effect<
QueryRunner<K, A, Exclude<E, HandledE>, R>,
never,
R | QueryClient.QueryClient<EH, HandledE> | EH
> => Effect.gen(function*() {
const context = yield* Effect.context<R | QueryClient.QueryClient<EH, HandledE> | EH>()
const latestKeyRef = yield* SubscriptionRef.make(Option.none<K>()) const latestKeyRef = yield* SubscriptionRef.make(Option.none<K>())
const stateRef = yield* SubscriptionRef.make(AsyncData.noData<A, E>()) const stateRef = yield* SubscriptionRef.make(AsyncData.noData<A, Exclude<E, HandledE>>())
const fiberRef = yield* SubscriptionRef.make(Option.none<Fiber.RuntimeFiber<void, Cause.NoSuchElementException>>()) const fiberRef = yield* SubscriptionRef.make(Option.none<Fiber.RuntimeFiber<void, Cause.NoSuchElementException>>())
const interrupt = fiberRef.pipe( const interrupt = fiberRef.pipe(
@@ -54,13 +64,17 @@ export const make = <K extends readonly unknown[], A, E, R>(
})) }))
) )
const run = latestKeyRef.pipe( const run = QueryClient.pipe(
Effect.flatMap(client => client.ErrorHandler),
Effect.flatMap(errorHandler => latestKeyRef.pipe(
Effect.flatMap(identity), Effect.flatMap(identity),
Effect.flatMap(key => query(key).pipe( Effect.flatMap(key => query(key).pipe(
errorHandler.handle,
Effect.matchCauseEffect({ Effect.matchCauseEffect({
onSuccess: v => Ref.set(stateRef, AsyncData.success(v)), onSuccess: v => Ref.set(stateRef, AsyncData.success(v)),
onFailure: c => Ref.set(stateRef, AsyncData.failure(c)), onFailure: c => Ref.set(stateRef, AsyncData.failure(c)),
}) }),
)),
)), )),
Effect.provide(context), Effect.provide(context),
@@ -118,7 +132,7 @@ export const make = <K extends readonly unknown[], A, E, R>(
) )
return { return {
query, context,
latestKeyRef, latestKeyRef,
stateRef, stateRef,

View File

@@ -1,3 +1,5 @@
export * as ErrorHandler from "./ErrorHandler.js"
export * as QueryClient from "./QueryClient.js"
export * from "./QueryExtension.js" export * from "./QueryExtension.js"
export * as QueryRunner from "./QueryRunner.js" export * as QueryRunner from "./QueryRunner.js"
export * as QueryService from "./QueryService.js" export * as QueryService from "./QueryService.js"