Add Effect v4 support, Fast Refresh tooling, and revamped docs #56

Merged
Thilawyn merged 133 commits from next into master 2026-07-26 02:32:59 +02:00
Showing only changes of commit e07ce1cd68 - Show all commits
+136 -93
View File
@@ -1,4 +1,4 @@
import { type Cause, type Context, Duration, Effect, Equal, Exit, Fiber, Option, Pipeable, Predicate, type Scope, Semaphore, Stream, SubscriptionRef } from "effect" import { type Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, type Scope, Semaphore, Stream, SubscriptionRef } from "effect"
import { AsyncResult } from "effect/unstable/reactivity" import { AsyncResult } from "effect/unstable/reactivity"
import * as Lens from "./Lens.js" import * as Lens from "./Lens.js"
import * as QueryClient from "./QueryClient.js" import * as QueryClient from "./QueryClient.js"
@@ -8,51 +8,61 @@ import * as Subscribable from "./Subscribable.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query") export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never> export interface Query<in out K, in out A, in out E = never, in out R = never>
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R> readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: K) => Effect.Effect<A, E, R> readonly f: (key: K) => Effect.Effect<A, E, R>
readonly keyEquivalence: Equivalence.Equivalence<K>
readonly staleTime: Duration.Duration readonly staleTime: Duration.Duration
readonly refreshOnWindowFocus: boolean readonly refreshOnWindowFocus: boolean
readonly latestKey: Subscribable.Subscribable<Option.Option<K>> readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>> readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>> readonly state: Subscribable.Subscribable<QueryState<K, A, E>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>> readonly latestFinalState: Subscribable.Subscribable<Option.Option<FinalQueryState<K, A, E>>>
readonly run: Effect.Effect<void> readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>, Cause.NoSuchElementError> fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>, Cause.NoSuchElementError> fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<QueryState<K, A, E>>, Cause.NoSuchElementError>
readonly refresh: Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>, Cause.NoSuchElementError> readonly refresh: Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError>
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>, Cause.NoSuchElementError> readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<QueryState<K, A, E>>, Cause.NoSuchElementError>
readonly invalidateCache: Effect.Effect<void> readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void> invalidateCacheEntry(key: K): Effect.Effect<void>
} }
export const isQuery = (u: unknown): u is Query<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId) export interface QueryState<in out K, in out A, in out E = never> {
readonly key: K
readonly result: AsyncResult.AsyncResult<A, E>
}
export interface FinalQueryState<in out K, in out A, in out E = never> {
readonly key: K
readonly result: AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>
}
export const isQuery = (u: unknown): u is Query<unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
export class QueryImpl<in out K, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never> export class QueryImpl<in out K, in out A, in out E = never, in out R = never>
extends Pipeable.Class implements Query<K, A, KE, KR, E, R> { extends Pipeable.Class implements Query<K, A, E, R> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId readonly [QueryTypeId]: QueryTypeId = QueryTypeId
constructor( constructor(
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>, readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
readonly key: Stream.Stream<K, KE, KR>,
readonly f: (key: K) => Effect.Effect<A, E, R>, readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly keyEquivalence: Equivalence.Equivalence<K>,
readonly staleTime: Duration.Duration, readonly staleTime: Duration.Duration,
readonly refreshOnWindowFocus: boolean, readonly refreshOnWindowFocus: boolean,
readonly latestKey: Lens.Lens<Option.Option<K>>, readonly latestKey: Lens.Lens<Option.Option<K>>,
readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>, readonly fiber: Lens.Lens<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: Lens.Lens<AsyncResult.AsyncResult<A, E>>, readonly state: Lens.Lens<QueryState<K, A, E>>,
readonly latestFinalResult: Lens.Lens<Option.Option<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>>, readonly latestFinalState: Lens.Lens<Option.Option<FinalQueryState<K, A, E>>>,
readonly runSemaphore: Semaphore.Semaphore, readonly runSemaphore: Semaphore.Semaphore,
) { ) {
@@ -60,10 +70,7 @@ extends Pipeable.Class implements Query<K, A, KE, KR, E, R> {
} }
get run(): Effect.Effect<void> { get run(): Effect.Effect<void> {
return Effect.all([ return Effect.promise(() => import("@effect/platform-browser")).pipe(
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
Effect.promise(() => import("@effect/platform-browser")).pipe(
Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus Effect.flatMap(({ BrowserStream }) => this.refreshOnWindowFocus
? Stream.runForEach( ? Stream.runForEach(
BrowserStream.fromEventListenerWindow("focus"), BrowserStream.fromEventListenerWindow("focus"),
@@ -72,8 +79,6 @@ extends Pipeable.Class implements Query<K, A, KE, KR, E, R> {
: Effect.void : Effect.void
), ),
Effect.catchDefect(() => Effect.void), Effect.catchDefect(() => Effect.void),
),
], { concurrency: "unbounded" }).pipe(
Effect.ignore, Effect.ignore,
this.runSemaphore.withPermits(1), this.runSemaphore.withPermits(1),
Effect.provide(this.context), Effect.provide(this.context),
@@ -87,117 +92,156 @@ extends Pipeable.Class implements Query<K, A, KE, KR, E, R> {
})) }))
} }
fetch(key: K): Effect.Effect< fetch(key: K): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>,
Cause.NoSuchElementError
> {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(Lens.set(this.latestKey, Option.some(key))), Effect.andThen(Lens.set(this.latestKey, Option.some(key))),
Effect.andThen(this.startCached(key, AsyncResult.initial())), Effect.andThen(this.startCached({
Effect.flatMap(state => this.watch(key, state)), key,
result: AsyncResult.initial(false)
})),
Effect.flatMap(state => this.watch(state)),
Effect.provide(this.context), Effect.provide(this.context),
) )
} }
fetchSubscribable(key: K): Effect.Effect< fetchSubscribable(key: K): Effect.Effect<
Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>, Subscribable.Subscribable<QueryState<K, A, E>>,
Cause.NoSuchElementError Cause.NoSuchElementError
> { > {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(Lens.set(this.latestKey, Option.some(key))), Effect.andThen(Lens.set(this.latestKey, Option.some(key))),
Effect.andThen(this.startCached(key, AsyncResult.initial(false))), Effect.andThen(this.startCached({
Effect.tap(state => Effect.forkScoped(this.watch(key, state))), key,
result: AsyncResult.initial(false)
})),
Effect.tap(state => Effect.forkScoped(this.watch(state))),
Effect.provide(this.context), Effect.provide(this.context),
) )
} }
get refresh(): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>, Cause.NoSuchElementError> { get refresh(): Effect.Effect<FinalQueryState<K, A, E>, Cause.NoSuchElementError> {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(Effect.Do), Effect.andThen(Effect.Do),
Effect.bind("latestKey", () => Effect.flatMap(Lens.get(this.latestKey), Effect.fromOption)), Effect.bind("latestKey", () => Effect.flatMap(Lens.get(this.latestKey), Effect.fromOption)),
Effect.bind("latestFinalResult", () => Lens.get(this.latestFinalResult)), Effect.bind("latestFinalState", () => Lens.get(this.latestFinalState)),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) => Effect.bind("subscribable", ({ latestKey, latestFinalState }) =>
this.startCached(latestKey, Option.getOrElse(latestFinalResult, () => AsyncResult.initial(true))) this.startCached(Option.getOrElse(latestFinalState, () => ({
key: latestKey,
result: AsyncResult.initial(true),
})))
), ),
Effect.flatMap(({ latestKey, subscribable }) => this.watch(latestKey, subscribable)), Effect.flatMap(({ subscribable }) => this.watch(subscribable)),
Effect.provide(this.context), Effect.provide(this.context),
) )
} }
get refreshSubscribable(): Effect.Effect< get refreshSubscribable(): Effect.Effect<
Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>, Subscribable.Subscribable<QueryState<K, A, E>>,
Cause.NoSuchElementError Cause.NoSuchElementError
> { > {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(Effect.Do), Effect.andThen(Effect.Do),
Effect.bind("latestKey", () => Effect.flatMap(Lens.get(this.latestKey), Effect.fromOption)), Effect.bind("latestKey", () => Effect.flatMap(Lens.get(this.latestKey), Effect.fromOption)),
Effect.bind("latestFinalResult", () => Lens.get(this.latestFinalResult)), Effect.bind("latestFinalState", () => Lens.get(this.latestFinalState)),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) => Effect.bind("subscribable", ({ latestKey, latestFinalState }) =>
this.startCached(latestKey, Option.getOrElse(latestFinalResult, () => AsyncResult.initial(true))) this.startCached(Option.getOrElse(latestFinalState, () => ({
key: latestKey,
result: AsyncResult.initial(true),
})))
), ),
Effect.tap(({ latestKey, subscribable }) => Effect.forkScoped(this.watch(latestKey, subscribable))), Effect.tap(({ subscribable }) => Effect.forkScoped(this.watch(subscribable))),
Effect.map(({ subscribable }) => subscribable), Effect.map(({ subscribable }) => subscribable),
Effect.provide(this.context), Effect.provide(this.context),
) )
} }
startCached( startCached(
key: K, previous: QueryState<K, A, E>,
previous: AsyncResult.AsyncResult<A, E>,
): Effect.Effect< ): Effect.Effect<
Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>, Subscribable.Subscribable<QueryState<K, A, E>>,
Cause.NoSuchElementError, Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | R Scope.Scope | QueryClient.QueryClient | R
> { > {
return Effect.flatMap(this.getCacheEntry(key), Option.match({ return Effect.flatMap(this.getCacheEntry(previous.key), Option.match({
onSome: entry => Effect.flatMap( onSome: entry => Effect.flatMap(
QueryClient.isQueryClientCacheEntryStale(entry), QueryClient.isQueryClientCacheEntryStale(entry),
isStale => isStale isStale => isStale
? this.start(key, entry.result as AsyncResult.AsyncResult<A, E>) ? this.start({
key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
})
: Effect.succeed(Subscribable.make({ : Effect.succeed(Subscribable.make({
get: Effect.succeed(entry.result as AsyncResult.AsyncResult<A, E>), get: Effect.succeed({
get changes() { return Stream.make(entry.result as AsyncResult.AsyncResult<A, E>) }, key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
}),
get changes() {
return Stream.make({
key: previous.key,
result: entry.result as AsyncResult.AsyncResult<A, E>,
})
},
})), })),
), ),
onNone: () => this.start(key, previous), onNone: () => this.start(previous),
})) }))
} }
start( start(
key: K, previous: QueryState<K, A, E>,
previous: AsyncResult.AsyncResult<A, E>,
): Effect.Effect< ): Effect.Effect<
Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>>, Subscribable.Subscribable<QueryState<K, A, E>>,
never, never,
Scope.Scope | R Scope.Scope | R
> { > {
return Effect.gen({ self: this }, function*() { return Effect.gen({ self: this }, function*() {
const state = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(previous)) const subscribable = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(previous))
const fiber = yield* Effect.forkScoped(Effect.andThen( const fiber = yield* Effect.forkScoped(Effect.andThen(
Lens.update(state, AsyncResult.match({ Lens.update(subscribable, previous => AsyncResult.match(previous.result, {
onInitial: () => AsyncResult.initial(true), onInitial: () => ({
onSuccess: v => AsyncResult.success(v.value, { key: previous.key,
result: AsyncResult.initial(true),
}),
onSuccess: result => ({
key: previous.key,
result: AsyncResult.success(result.value, {
waiting: true, waiting: true,
}), }),
onFailure: v => AsyncResult.failure(v.cause, { }),
onFailure: result => ({
key: previous.key,
result: AsyncResult.failure(result.cause, {
waiting: true, waiting: true,
previousSuccess: v.previousSuccess, previousSuccess: result.previousSuccess,
}) }),
}),
})), })),
Effect.onExit(this.f(key), exit => Lens.update( Effect.onExit(this.f(previous.key), exit => Lens.update(
state, subscribable,
previous => Exit.match(exit, { previous => Exit.match(exit, {
onSuccess: v => AsyncResult.success(v), onSuccess: v => ({
onFailure: c => AsyncResult.match(previous, { key: previous.key,
onInitial: () => AsyncResult.failure(c), result: AsyncResult.success(v),
onSuccess: v => AsyncResult.failure(c, { }),
onFailure: c => AsyncResult.match(previous.result, {
onInitial: () => ({
key: previous.key,
result: AsyncResult.failure(c),
}),
onSuccess: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: Option.some(v), previousSuccess: Option.some(v),
}), }),
onFailure: v => AsyncResult.failure(c, { }),
onFailure: v => ({
key: previous.key,
result: AsyncResult.failure(c, {
previousSuccess: v.previousSuccess, previousSuccess: v.previousSuccess,
}) }),
}),
}), }),
}), }),
).pipe( ).pipe(
@@ -206,7 +250,7 @@ extends Pipeable.Class implements Query<K, A, KE, KR, E, R> {
Lens.get(this.fiber), Lens.get(this.fiber),
])), ])),
Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, { Effect.flatMap(([fiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(fiberId, v.id) onSome: v => fiberId === v.id
? Lens.set(this.fiber, Option.none()) ? Lens.set(this.fiber, Option.none())
: Effect.void, : Effect.void,
onNone: () => Effect.void, onNone: () => Effect.void,
@@ -215,23 +259,22 @@ extends Pipeable.Class implements Query<K, A, KE, KR, E, R> {
)) ))
yield* Lens.set(this.fiber, Option.some(fiber)) yield* Lens.set(this.fiber, Option.some(fiber))
return state return subscribable
}) })
} }
watch( watch(
key: K, subscribable: Subscribable.Subscribable<QueryState<K, A, E>>
state: Subscribable.Subscribable<AsyncResult.AsyncResult<A, E>> ): Effect.Effect<FinalQueryState<K, A, E>, never, QueryClient.QueryClient> {
): Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>, never, QueryClient.QueryClient> { return subscribable.get.pipe(
return state.get.pipe(
Effect.flatMap(initial => Stream.runFoldEffect( Effect.flatMap(initial => Stream.runFoldEffect(
state.changes, subscribable.changes,
() => initial, () => initial,
(_, result) => Effect.as(Lens.set(this.result, result), result), (_, state) => Effect.as(Lens.set(this.state, state), state),
) as Effect.Effect<AsyncResult.Success<A, E> | AsyncResult.Failure<A, E>>), ) as Effect.Effect<FinalQueryState<K, A, E>>),
Effect.tap(result => Lens.set(this.latestFinalResult, Option.some(result))), Effect.tap(state => Lens.set(this.latestFinalState, Option.some(state))),
Effect.tap(result => AsyncResult.isSuccess(result) Effect.tap(state => AsyncResult.isSuccess(state.result)
? this.setCacheEntry(key, result) ? this.setCacheEntry(state.key, state.result)
: Effect.void : Effect.void
), ),
) )
@@ -285,27 +328,27 @@ extends Pipeable.Class implements Query<K, A, KE, KR, E, R> {
} }
export declare namespace make { export declare namespace make {
export interface Options<K, A, KE = never, KR = never, E = never, R = never> { export interface Options<K, A, E = never, R = never> {
readonly key: Stream.Stream<K, KE, KR> readonly f: (key: K) => Effect.Effect<A, E, R>
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, R> readonly keyEquivalence?: Equivalence.Equivalence<K>,
readonly staleTime?: Duration.Input readonly staleTime?: Duration.Input
readonly refreshOnWindowFocus?: boolean readonly refreshOnWindowFocus?: boolean
} }
} }
export const make = Effect.fnUntraced(function* <K, A, KE = never, KR = never, E = never, R = never>( export const make = Effect.fnUntraced(function* <K, A, E = never, R = never>(
options: make.Options<K, A, KE, KR, E, R> options: make.Options<K, A, E, R>
): Effect.fn.Return< ): Effect.fn.Return<
Query<K, A, KE, KR, E, R>, Query<K, A, E, R>,
Cause.NoSuchElementError, Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | KR | R Scope.Scope | QueryClient.QueryClient | R
> { > {
const client = yield* QueryClient.QueryClient const client = yield* QueryClient.QueryClient
return new QueryImpl( return new QueryImpl(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | R>(), yield* Effect.context<Scope.Scope | QueryClient.QueryClient | R>(),
options.key,
options.f, options.f,
options.keyEquivalence ?? Equal.asEquivalence(),
options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime, options.staleTime ? yield* Effect.fromOption(Duration.fromInput(options.staleTime)) : client.defaultStaleTime,
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus, options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
@@ -319,12 +362,12 @@ export const make = Effect.fnUntraced(function* <K, A, KE = never, KR = never, E
) )
}) })
export const service = <K, A, KE = never, KR = never, E = never, R = never>( export const service = <K, A, E = never, R = never>(
options: make.Options<K, A, KE, KR, E, R> options: make.Options<K, A, E, R>
): Effect.Effect< ): Effect.Effect<
Query<K, A, KE, KR, E, R>, Query<K, A, E, R>,
Cause.NoSuchElementError, Cause.NoSuchElementError,
Scope.Scope | QueryClient.QueryClient | KR | R Scope.Scope | QueryClient.QueryClient | R
> => Effect.tap( > => Effect.tap(
make(options), make(options),
query => Effect.forkScoped(query.run), query => Effect.forkScoped(query.run),