Compare commits

..

24 Commits

Author SHA1 Message Date
edd5b69d17 Update dependency globals to v17
Some checks failed
Lint / lint (push) Failing after 3s
Test build / test-build (pull_request) Failing after 4s
2026-01-16 14:21:14 +01:00
Julien Valverdé
e744e614ad Cleanup
Some checks failed
Lint / lint (push) Failing after 3s
2026-01-16 14:19:14 +01:00
Julien Valverdé
5451c84d34 Version bump
Some checks failed
Lint / lint (push) Failing after 4s
2026-01-16 14:10:58 +01:00
Julien Valverdé
696fa21fab Refactor Form
Some checks failed
Lint / lint (push) Failing after 3s
2026-01-16 11:52:21 +01:00
Julien Valverdé
7b2e28451a Refactor Form
Some checks failed
Lint / lint (push) Failing after 3s
2026-01-16 11:49:11 +01:00
Julien Valverdé
93f65c5016 Add query refreshOnWindowFocus
Some checks failed
Lint / lint (push) Failing after 33s
2026-01-16 10:48:55 +01:00
Julien Valverdé
95d0bf70bd Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-15 22:24:36 +01:00
Julien Valverdé
dad4cd60d1 Refactor query
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-15 22:19:56 +01:00
Julien Valverdé
731eed4209 Refactor Query
All checks were successful
Lint / lint (push) Successful in 13s
2026-01-15 19:19:37 +01:00
Julien Valverdé
8c22206ad7 Refactor Mutation
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-15 19:07:54 +01:00
Julien Valverdé
a9ed86c4a8 Query done
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-15 18:26:02 +01:00
Julien Valverdé
4f9441c89c Refactor
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-14 23:08:52 +01:00
Julien Valverdé
4f9bfaafaa Add removeCacheEntry
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-14 22:56:57 +01:00
Julien Valverdé
b8a3b089b7 Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-14 22:38:12 +01:00
Julien Valverdé
b29dec7d30 Fix
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-14 22:36:40 +01:00
Julien Valverdé
d1ef42e9cb Fix example
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-14 12:47:48 +01:00
Julien Valverdé
c029f85401 Fix Form
All checks were successful
Lint / lint (push) Successful in 12s
2026-01-14 12:43:59 +01:00
Julien Valverdé
8203063253 Refactor Query
Some checks failed
Lint / lint (push) Failing after 11s
2026-01-14 12:42:16 +01:00
Julien Valverdé
931511b890 Refactor Result 2026-01-14 10:29:52 +01:00
Julien Valverdé
7705880afe Refactor Result equals and hash
Some checks failed
Lint / lint (push) Failing after 11s
2026-01-14 10:08:22 +01:00
Julien Valverdé
0f79f12632 Refactor Result
Some checks failed
Lint / lint (push) Failing after 39s
2026-01-14 09:18:34 +01:00
Julien Valverdé
cb788952a4 Refactor Result
Some checks failed
Lint / lint (push) Failing after 42s
2026-01-13 11:11:53 +01:00
Julien Valverdé
cd18a9d108 Query work
All checks were successful
Lint / lint (push) Successful in 13s
2026-01-11 13:08:16 +01:00
Julien Valverdé
a6d91a93a5 Cached queries
All checks were successful
Lint / lint (push) Successful in 53s
2026-01-11 11:08:32 +01:00
8 changed files with 338 additions and 241 deletions

View File

@@ -17,6 +17,9 @@
"packages/effect-fc": { "packages/effect-fc": {
"name": "effect-fc", "name": "effect-fc",
"version": "0.2.1", "version": "0.2.1",
"devDependencies": {
"@effect/platform-browser": "^0.74.0",
},
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "^3.19.0", "effect": "^3.19.0",
@@ -655,6 +658,8 @@
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"effect-fc/@effect/platform-browser": ["@effect/platform-browser@0.74.0", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-PAgkg5L5cASQpScA0SZTSy543MVA4A9kmpVCjo2fCINLRpTeuCFAOQHgPmw8dKHnYS0yGs2TYn7AlrhhqQ5o3g=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],

View File

@@ -1,7 +1,7 @@
{ {
"name": "effect-fc", "name": "effect-fc",
"description": "Write React function components with Effect", "description": "Write React function components with Effect",
"version": "0.2.1", "version": "0.2.2",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
@@ -37,6 +37,9 @@
"clean:dist": "rm -rf dist", "clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": {
"@effect/platform-browser": "^0.74.0"
},
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"effect": "^3.19.0", "effect": "^3.19.0",

View File

@@ -35,6 +35,8 @@ extends Pipeable.Pipeable {
field<const P extends PropertyPath.Paths<I>>( field<const P extends PropertyPath.Paths<I>>(
path: P path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>> ): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
} }
@@ -68,7 +70,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
Option.isSome(value) && Option.isSome(value) &&
Option.isNone(error) && Option.isNone(error) &&
Option.isNone(validationFiber) && Option.isNone(validationFiber) &&
!(Result.isRunning(result) || Result.isRefreshing(result)) !(Result.isRunning(result) || Result.hasRefreshingFlag(result))
), ),
) )
} }
@@ -89,7 +91,49 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
) )
} }
readonly canSubmit: Subscribable.Subscribable<boolean, never, never> readonly canSubmit: Subscribable.Subscribable<boolean>
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Stream.runForEach(
this.encodedValue.changes.pipe(
Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity
),
encodedValue => this.validationFiber.pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.forkScoped(Effect.onExit(
Schema.decode(this.schema, { errors: "all" })(encodedValue),
exit => Effect.andThen(
Exit.matchEffect(exit, {
onSuccess: v => Effect.andThen(
Ref.set(this.value, Option.some(v)),
Ref.set(this.error, Option.none()),
),
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
onSome: e => Ref.set(this.error, Option.some(e)),
onNone: () => Effect.void,
}),
}),
Ref.set(this.validationFiber, Option.none()),
),
)).pipe(
Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))),
Effect.andThen(Fiber.join),
Effect.andThen(value => this.autosubmit
? Effect.asVoid(Effect.forkScoped(this.submitValue(value)))
: Effect.void
),
Effect.forkScoped,
)
),
Effect.provide(this.context),
),
))
}
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> { get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
return this.value.pipe( return this.value.pipe(
@@ -97,6 +141,7 @@ extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
Effect.andThen(value => this.submitValue(value)), Effect.andThen(value => this.submitValue(value)),
) )
} }
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> { submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> {
return Effect.whenEffect( return Effect.whenEffect(
Effect.tap( Effect.tap(
@@ -158,51 +203,6 @@ export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void,
) )
}) })
export const run = <A, I, R, MA, ME, MR, MP>(
self: Form<A, I, R, MA, ME, MR, MP>
): Effect.Effect<void> => {
const _self = self as FormImpl<A, I, R, MA, ME, MR, MP>
return _self.runSemaphore.withPermits(1)(Stream.runForEach(
_self.encodedValue.changes.pipe(
Option.isSome(_self.debounce) ? Stream.debounce(_self.debounce.value) : identity
),
encodedValue => _self.validationFiber.pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.forkScoped(Effect.onExit(
Schema.decode(_self.schema, { errors: "all" })(encodedValue),
exit => Effect.andThen(
Exit.matchEffect(exit, {
onSuccess: v => Effect.andThen(
Ref.set(_self.value, Option.some(v)),
Ref.set(_self.error, Option.none()),
),
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
onSome: e => Ref.set(_self.error, Option.some(e)),
onNone: () => Effect.void,
}),
}),
Ref.set(_self.validationFiber, Option.none()),
),
)).pipe(
Effect.tap(fiber => Ref.set(_self.validationFiber, Option.some(fiber))),
Effect.andThen(Fiber.join),
Effect.andThen(value => _self.autosubmit
? Effect.asVoid(Effect.forkScoped(_self.submitValue(value)))
: Effect.void
),
Effect.forkScoped,
)
),
Effect.provide(_self.context),
),
))
}
export declare namespace service { export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never> export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends make.Options<A, I, R, MA, ME, MR, MP> {} extends make.Options<A, I, R, MA, ME, MR, MP> {}
@@ -216,7 +216,7 @@ export const service = <A, I = A, R = never, MA = void, ME = never, MR = never,
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP> Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
> => Effect.tap( > => Effect.tap(
make(options), make(options),
form => Effect.forkScoped(run(form)), form => Effect.forkScoped(form.run),
) )
@@ -271,22 +271,21 @@ export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyP
self: Form<A, I, R, MA, ME, MR, MP>, self: Form<A, I, R, MA, ME, MR, MP>,
path: P, path: P,
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => { ): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
const _self = self as FormImpl<A, I, R, MA, ME, MR, MP>
return new FormFieldImpl( return new FormFieldImpl(
Subscribable.mapEffect(_self.value, Option.match({ Subscribable.mapEffect(self.value, Option.match({
onSome: v => Option.map(PropertyPath.get(v, path), Option.some), onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
onNone: () => Option.some(Option.none()), onNone: () => Option.some(Option.none()),
})), })),
SubscriptionSubRef.makeFromPath(_self.encodedValue, path), SubscriptionSubRef.makeFromPath(self.encodedValue, path),
Subscribable.mapEffect(_self.error, Option.match({ Subscribable.mapEffect(self.error, Option.match({
onSome: flow( onSome: flow(
ParseResult.ArrayFormatter.formatError, ParseResult.ArrayFormatter.formatError,
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))), Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
), ),
onNone: () => Effect.succeed([]), onNone: () => Effect.succeed([]),
})), })),
Subscribable.map(_self.validationFiber, Option.isSome), Subscribable.map(self.validationFiber, Option.isSome),
Subscribable.map(_self.mutation.result, result => Result.isRunning(result) || Result.isRefreshing(result)), Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
) )
} }

View File

@@ -16,6 +16,7 @@ extends Pipeable.Pipeable {
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<Result.Result<A, E, P>> readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
mutate(key: K): Effect.Effect<Result.Final<A, E, P>> mutate(key: K): Effect.Effect<Result.Final<A, E, P>>
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
@@ -30,27 +31,30 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
readonly [MutationTypeId]: MutationTypeId = MutationTypeId readonly [MutationTypeId]: MutationTypeId = MutationTypeId
constructor( constructor(
readonly context: Context.Context<Scope.Scope | NoInfer<R>>, readonly context: Context.Context<Scope.Scope | R>,
readonly f: (key: K) => Effect.Effect<A, E, R>, readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P, readonly initialProgress: P,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>, readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>, readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>, readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
) { ) {
super() super()
} }
mutate(key: K): Effect.Effect<Result.Final<A, E, P>> { mutate(key: K): Effect.Effect<Result.Final<A, E, P>> {
return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe( return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(Effect.provide(this.start(key), this.context)), Effect.andThen(this.start(key)),
Effect.andThen(sub => this.watch(sub)), Effect.andThen(sub => this.watch(sub)),
Effect.provide(this.context),
) )
} }
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> { mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return Effect.andThen( return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
SubscriptionRef.set(this.latestKey, Option.some(key)), Effect.andThen(this.start(key)),
Effect.provide(this.start(key), this.context) Effect.tap(sub => Effect.forkScoped(this.watch(sub))),
Effect.provide(this.context),
) )
} }
@@ -59,12 +63,8 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
never, never,
Scope.Scope | R Scope.Scope | R
> { > {
return this.result.pipe( return this.latestFinalResult.pipe(
Effect.map(previous => Result.isFinal(previous) Effect.andThen(initial => Result.unsafeForkEffect(
? previous
: undefined
),
Effect.andThen(previous => Result.unsafeForkEffect(
Effect.onExit(this.f(key), () => Effect.andThen( Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]), Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, { ([currentFiberId, fiber]) => Option.match(fiber, {
@@ -72,12 +72,12 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
? SubscriptionRef.set(this.fiber, Option.none()) ? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void, : Effect.void,
onNone: () => Effect.void, onNone: () => Effect.void,
}) }),
)), )),
{ {
initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(),
initialProgress: this.initialProgress, initialProgress: this.initialProgress,
previous,
} as Result.unsafeForkEffect.Options<A, E, P>, } as Result.unsafeForkEffect.Options<A, E, P>,
)), )),
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))), Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
@@ -88,14 +88,14 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
watch( watch(
sub: Subscribable.Subscribable<Result.Result<A, E, P>> sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> { ): Effect.Effect<Result.Final<A, E, P>> {
return Effect.andThen( return sub.get.pipe(
sub.get, Effect.andThen(initial => Stream.runFoldEffect(
initial => Stream.runFoldEffect( sub.changes,
Stream.filter(sub.changes, Predicate.not(Result.isInitial)),
initial, initial,
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result), (_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
), ) as Effect.Effect<Result.Final<A, E, P>>),
) as Effect.Effect<Result.Final<A, E, P>> Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
)
} }
} }
@@ -123,5 +123,6 @@ export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey
yield* SubscriptionRef.make(Option.none<K>()), yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()), yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()), yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
) )
}) })

View File

@@ -1,4 +1,4 @@
import { type Cause, type Context, DateTime, type Duration, Effect, Fiber, HashMap, identity, Option, Pipeable, Predicate, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect" import { type Cause, type Context, DateTime, type Duration, Effect, Equal, Equivalence, Fiber, HashMap, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
import * as QueryClient from "./QueryClient.js" import * as QueryClient from "./QueryClient.js"
import * as Result from "./Result.js" import * as Result from "./Result.js"
@@ -16,17 +16,21 @@ extends Pipeable.Pipeable {
readonly initialProgress: P readonly initialProgress: P
readonly staleTime: Duration.DurationInput readonly staleTime: Duration.DurationInput
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<Result.Result<A, E, P>> readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<Result.Final<A, E, P>> fetch(key: K): Effect.Effect<Result.Final<A, E, P>>
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
readonly refetch: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException>
readonly refetchSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException>
readonly refresh: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> readonly refresh: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException>
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException> readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException>
readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void>
} }
export declare namespace Query { export declare namespace Query {
@@ -44,16 +48,38 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
readonly initialProgress: P, readonly initialProgress: P,
readonly staleTime: Duration.DurationInput, readonly staleTime: Duration.DurationInput,
readonly refreshOnWindowFocus: boolean,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>, readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>, readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>, readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
readonly runSemaphore: Effect.Semaphore, readonly runSemaphore: Effect.Semaphore,
) { ) {
super() super()
} }
get run(): Effect.Effect<void> {
return Effect.all([
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
Effect.promise(() => import("@effect/platform-browser")).pipe(
Effect.andThen(({ BrowserStream }) => this.refreshOnWindowFocus
? Stream.runForEach(
BrowserStream.fromEventListenerWindow("focus"),
() => this.refreshSubscribable,
)
: Effect.void
),
Effect.catchAllDefect(() => Effect.void),
),
], { concurrency: "unbounded" }).pipe(
Effect.ignore,
this.runSemaphore.withPermits(1),
)
}
get interrupt(): Effect.Effect<void, never, never> { get interrupt(): Effect.Effect<void, never, never> {
return Effect.andThen(this.fiber, Option.match({ return Effect.andThen(this.fiber, Option.match({
onSome: Fiber.interrupt, onSome: Fiber.interrupt,
@@ -64,84 +90,136 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
fetch(key: K): Effect.Effect<Result.Final<A, E, P>> { fetch(key: K): Effect.Effect<Result.Final<A, E, P>> {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))), Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(Effect.provide(this.start(key), this.context)), Effect.andThen(this.latestFinalResult),
Effect.andThen(sub => this.watch(sub)), Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial()
)),
Effect.andThen(sub => this.watch(key, sub)),
Effect.provide(this.context),
) )
} }
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> { fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))), Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(Effect.provide(this.start(key), this.context)), Effect.andThen(this.latestFinalResult),
) Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
} ? Result.willFetch(previous.value) as Result.Final<A, E, P>
get refetch(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> { : Result.initial()
return this.interrupt.pipe( )),
Effect.andThen(this.latestKey), Effect.tap(sub => Effect.forkScoped(this.watch(key, sub))),
Effect.andThen(identity), Effect.provide(this.context),
Effect.andThen(key => Effect.provide(this.start(key), this.context)),
Effect.andThen(sub => this.watch(sub)),
)
}
get refetchSubscribable(): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key), this.context)),
) )
} }
get refresh(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> { get refresh(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(this.latestKey), Effect.andThen(Effect.Do),
Effect.andThen(identity), Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
Effect.andThen(key => Effect.provide(this.start(key, true), this.context)), Effect.bind("latestFinalResult", () => this.latestFinalResult),
Effect.andThen(sub => this.watch(sub)), Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
this.startCached(latestKey, Option.isSome(latestFinalResult)
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
: Result.initial()
)
),
Effect.andThen(({ latestKey, subscribable }) => this.watch(latestKey, subscribable)),
Effect.provide(this.context),
) )
} }
get refreshSubscribable(): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException> {
get refreshSubscribable(): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
Cause.NoSuchElementException
> {
return this.interrupt.pipe( return this.interrupt.pipe(
Effect.andThen(this.latestKey), Effect.andThen(Effect.Do),
Effect.andThen(identity), Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
Effect.andThen(key => Effect.provide(this.start(key, true), this.context)), Effect.bind("latestFinalResult", () => this.latestFinalResult),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
this.startCached(latestKey, Option.isSome(latestFinalResult)
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
: Result.initial()
) )
),
Effect.tap(({ latestKey, subscribable }) => Effect.forkScoped(this.watch(latestKey, subscribable))),
Effect.map(({ subscribable }) => subscribable),
Effect.provide(this.context),
)
}
startCached(
key: K,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | QueryClient.QueryClient | R
> {
return Effect.andThen(this.getCacheEntry(key), Option.match({
onSome: entry => Effect.andThen(
QueryClient.isQueryClientCacheEntryStale(entry, this.staleTime),
isStale => isStale
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
: Effect.succeed(Subscribable.make({
get: Effect.succeed(entry.result as Result.Result<A, E, P>),
get changes() { return Stream.make(entry.result as Result.Result<A, E, P>) },
})),
),
onNone: () => this.start(key, initial),
}))
} }
start( start(
key: K, key: K,
refresh?: boolean, initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect< ): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>, Subscribable.Subscribable<Result.Result<A, E, P>>,
never, never,
Scope.Scope | R Scope.Scope | R
> { > {
return this.result.pipe( return Result.unsafeForkEffect(
Effect.map(previous => Result.isFinal(previous) Effect.onExit(this.f(key), () => Effect.andThen(
? previous Effect.all([Effect.fiberId, this.fiber]),
: undefined ([currentFiberId, fiber]) => Option.match(fiber, {
), onSome: v => Equal.equals(currentFiberId, v.id())
Effect.andThen(previous => Result.unsafeForkEffect( ? SubscriptionRef.set(this.fiber, Option.none())
Effect.onExit(this.f(key), () => SubscriptionRef.set(this.fiber, Option.none())), : Effect.void,
{ onNone: () => Effect.void,
initialProgress: this.initialProgress, }),
refresh: refresh && previous,
previous,
} as Result.unsafeForkEffect.Options<A, E, P>,
)), )),
{
initial,
initialProgress: this.initialProgress,
} as Result.unsafeForkEffect.Options<A, E, P>,
).pipe(
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))), Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
Effect.map(([sub]) => sub), Effect.map(([sub]) => sub),
) )
} }
watch( watch(
key: K,
sub: Subscribable.Subscribable<Result.Result<A, E, P>> sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> { ): Effect.Effect<Result.Final<A, E, P>, never, QueryClient.QueryClient> {
return Effect.andThen( return sub.get.pipe(
sub.get, Effect.andThen(initial => Stream.runFoldEffect(
initial => Stream.runFoldEffect( sub.changes,
Stream.filter(sub.changes, Predicate.not(Result.isInitial)),
initial, initial,
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result), (_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
Effect.tap(result => Result.isSuccess(result)
? this.updateCacheEntry(key, result)
: Effect.void
), ),
) as Effect.Effect<Result.Final<A, E, P>> )
}
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
return new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)
} }
getCacheEntry( getCacheEntry(
@@ -149,7 +227,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> { ): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
return QueryClient.QueryClient.pipe( return QueryClient.QueryClient.pipe(
Effect.andThen(client => client.cache), Effect.andThen(client => client.cache),
Effect.map(HashMap.get(new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>))), Effect.map(HashMap.get(this.makeCacheKey(key))),
) )
} }
@@ -163,11 +241,31 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now)), Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now)),
Effect.tap(({ client, entry }) => SubscriptionRef.update( Effect.tap(({ client, entry }) => SubscriptionRef.update(
client.cache, client.cache,
HashMap.set(new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>), entry), HashMap.set(this.makeCacheKey(key), entry),
)), )),
Effect.map(({ entry }) => entry), Effect.map(({ entry }) => entry),
) )
} }
get invalidateCache(): Effect.Effect<void> {
return QueryClient.QueryClient.pipe(
Effect.andThen(client => SubscriptionRef.update(
client.cache,
HashMap.filter((_, key) => !Equivalence.strict()(key.f, this.f)),
)),
Effect.provide(this.context),
)
}
invalidateCacheEntry(key: K): Effect.Effect<void> {
return QueryClient.QueryClient.pipe(
Effect.andThen(client => SubscriptionRef.update(
client.cache,
HashMap.remove(this.makeCacheKey(key)),
)),
Effect.provide(this.context),
)
}
} }
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId) export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
@@ -178,6 +276,7 @@ export declare namespace make {
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>> readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P readonly initialProgress?: P
readonly staleTime?: Duration.DurationInput readonly staleTime?: Duration.DurationInput
readonly refreshOnWindowFocus?: boolean
} }
} }
@@ -197,10 +296,12 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E =
options.initialProgress as P, options.initialProgress as P,
options.staleTime ?? client.defaultStaleTime, options.staleTime ?? client.defaultStaleTime,
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
yield* SubscriptionRef.make(Option.none<K>()), yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()), yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()), yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
yield* Effect.makeSemaphore(1), yield* Effect.makeSemaphore(1),
) )
@@ -214,18 +315,5 @@ export const service = <K extends Query.AnyKey, A, E = never, R = never, P = nev
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P> Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
> => Effect.tap( > => Effect.tap(
make(options), make(options),
query => Effect.forkScoped(run(query)), query => Effect.forkScoped(query.run),
) )
export const run = <K extends Query.AnyKey, A, E, R, P>(
self: Query<K, A, E, R, P>
): Effect.Effect<void> => {
const _self = self as QueryImpl<K, A, E, R, P>
return Stream.runForEach(_self.key, key => _self.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(_self.latestKey, Option.some(key))),
Effect.andThen(_self.start(key)),
Effect.andThen(sub => Effect.forkScoped(_self.watch(sub))),
Effect.provide(_self.context),
_self.runSemaphore.withPermits(1),
))
}

View File

@@ -1,4 +1,4 @@
import { type DateTime, type Duration, Effect, Equal, Equivalence, Hash, HashMap, Pipeable, Predicate, type Scope, SubscriptionRef } from "effect" import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Pipeable, Predicate, type Scope, SubscriptionRef } from "effect"
import type * as Query from "./Query.js" import type * as Query from "./Query.js"
import type * as Result from "./Result.js" import type * as Result from "./Result.js"
@@ -11,6 +11,7 @@ export interface QueryClientService extends Pipeable.Pipeable {
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>> readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly gcTime: Duration.DurationInput readonly gcTime: Duration.DurationInput
readonly defaultStaleTime: Duration.DurationInput readonly defaultStaleTime: Duration.DurationInput
readonly defaultRefreshOnWindowFocus: boolean
} }
export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", { export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
@@ -26,6 +27,7 @@ implements QueryClientService {
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>, readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly gcTime: Duration.DurationInput, readonly gcTime: Duration.DurationInput,
readonly defaultStaleTime: Duration.DurationInput, readonly defaultStaleTime: Duration.DurationInput,
readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Effect.Semaphore, readonly runSemaphore: Effect.Semaphore,
) { ) {
super() super()
@@ -38,6 +40,7 @@ export declare namespace make {
export interface Options { export interface Options {
readonly gcTime?: Duration.DurationInput readonly gcTime?: Duration.DurationInput
readonly defaultStaleTime?: Duration.DurationInput readonly defaultStaleTime?: Duration.DurationInput
readonly defaultRefreshOnWindowFocus?: boolean
} }
} }
@@ -46,6 +49,7 @@ export const make = Effect.fnUntraced(function* (options: make.Options = {}): Ef
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()), yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.gcTime ?? "5 minutes", options.gcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes", options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true,
yield* Effect.makeSemaphore(1), yield* Effect.makeSemaphore(1),
) )
}) })
@@ -105,3 +109,11 @@ implements Pipeable.Pipeable {
} }
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId) export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
export const isQueryClientCacheEntryStale = (
self: QueryClientCacheEntry,
staleTime: Duration.DurationInput,
): Effect.Effect<boolean> => Effect.andThen(
DateTime.now,
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), staleTime),
)

View File

@@ -1,4 +1,4 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Option, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect" import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect"
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result") export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
@@ -8,18 +8,13 @@ export type Result<A, E = never, P = never> = (
| Initial | Initial
| Running<P> | Running<P>
| Final<A, E, P> | Final<A, E, P>
// biome-ignore lint/complexity/noBannedTypes: relevant here
) & ({} | Optimistic)
export type Final<A, E = never, P = never> = (
& (Success<A> | Failure<A, E>)
// biome-ignore lint/complexity/noBannedTypes: relevant here
& ({} | Refreshing<P>)
// biome-ignore lint/complexity/noBannedTypes: relevant here
& ({} | Optimistic)
) )
export namespace Result { // biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here
export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | Flags<P>)
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
export declare namespace Result {
export interface Prototype extends Pipeable.Pipeable, Equal.Equal { export interface Prototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId readonly [ResultTypeId]: ResultTypeId
} }
@@ -29,6 +24,10 @@ export namespace Result {
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
} }
export declare namespace Flags {
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
}
export interface Initial extends Result.Prototype { export interface Initial extends Result.Prototype {
readonly _tag: "Initial" readonly _tag: "Initial"
} }
@@ -43,62 +42,56 @@ export interface Success<A> extends Result.Prototype {
readonly value: A readonly value: A
} }
export interface Failure<A, E = never> extends Result.Prototype { export interface Failure<E = never> extends Result.Prototype {
readonly _tag: "Failure" readonly _tag: "Failure"
readonly cause: Cause.Cause<E> readonly cause: Cause.Cause<E>
readonly previousSuccess: Option.Option<Success<A>> }
export interface WillFetch {
readonly _flag: "WillFetch"
}
export interface WillRefresh {
readonly _flag: "WillRefresh"
} }
export interface Refreshing<P = never> { export interface Refreshing<P = never> {
readonly refreshing: true readonly _flag: "Refreshing"
readonly progress: P readonly progress: P
} }
export interface Optimistic {
readonly optimistic: true
}
const ResultPrototype = Object.freeze({ const ResultPrototype = Object.freeze({
...Pipeable.Prototype, ...Pipeable.Prototype,
[ResultTypeId]: ResultTypeId, [ResultTypeId]: ResultTypeId,
[Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean { [Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean {
if (this._tag !== that._tag) if (this._tag !== that._tag || (this as Flags)._flag !== (that as Flags)._flag)
return false
if (hasRefreshingFlag(this) && !Equal.equals(this.progress, (that as Refreshing<any>).progress))
return false return false
return Match.value(this).pipe( return Match.value(this).pipe(
Match.tag("Initial", () => true), Match.tag("Initial", () => true),
Match.tag("Running", self => Equal.equals(self.progress, (that as Running<any>).progress)), Match.tag("Running", self => Equal.equals(self.progress, (that as Running<any>).progress)),
Match.tag("Success", self => Match.tag("Success", self => Equal.equals(self.value, (that as Success<any>).value)),
Equal.equals(self.value, (that as Success<any>).value) && Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure<any>).cause)),
(isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) &&
Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined)
),
Match.tag("Failure", self =>
Equal.equals(self.cause, (that as Failure<any, any>).cause) &&
(isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) &&
Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined)
),
Match.exhaustive, Match.exhaustive,
) )
}, },
[Hash.symbol](this: Result<any, any, any>): number { [Hash.symbol](this: Result<any, any, any>): number {
const tagHash = Hash.string(this._tag) return pipe(Hash.string(this._tag),
tagHash => Match.value(this).pipe(
return Match.value(this).pipe(
Match.tag("Initial", () => tagHash), Match.tag("Initial", () => tagHash),
Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)), Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)),
Match.tag("Success", self => pipe(tagHash, Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)),
Hash.combine(Hash.hash(self.value)), Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)),
Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)),
)),
Match.tag("Failure", self => pipe(tagHash,
Hash.combine(Hash.hash(self.cause)),
Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)),
)),
Match.exhaustive, Match.exhaustive,
),
Hash.combine(Hash.hash((this as Flags)._flag)),
hash => hasRefreshingFlag(this)
? Hash.combine(Hash.hash(this.progress))(hash)
: hash,
Hash.cached(this), Hash.cached(this),
) )
}, },
@@ -110,9 +103,11 @@ export const isFinal = (u: unknown): u is Final<unknown, unknown, unknown> => is
export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial" export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial"
export const isRunning = (u: unknown): u is Running<unknown> => isResult(u) && u._tag === "Running" export const isRunning = (u: unknown): u is Running<unknown> => isResult(u) && u._tag === "Running"
export const isSuccess = (u: unknown): u is Success<unknown> => isResult(u) && u._tag === "Success" export const isSuccess = (u: unknown): u is Success<unknown> => isResult(u) && u._tag === "Success"
export const isFailure = (u: unknown): u is Failure<unknown, unknown> => isResult(u) && u._tag === "Failure" export const isFailure = (u: unknown): u is Failure<unknown> => isResult(u) && u._tag === "Failure"
export const isRefreshing = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "refreshing") && u.refreshing export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag")
export const isOptimistic = (u: unknown): u is Optimistic => isResult(u) && Predicate.hasProperty(u, "optimistic") && u.optimistic export const hasWillFetchFlag = (u: unknown): u is WillFetch => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillFetch"
export const hasWillRefreshFlag = (u: unknown): u is WillRefresh => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillRefresh"
export const hasRefreshingFlag = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing"
export const initial: { export const initial: {
(): Initial (): Initial
@@ -120,41 +115,42 @@ export const initial: {
} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype) } = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype)
export const running = <P = never>(progress?: P): Running<P> => Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype) export const running = <P = never>(progress?: P): Running<P> => Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype)
export const succeed = <A>(value: A): Success<A> => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype) export const succeed = <A>(value: A): Success<A> => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype)
export const fail = <E>(cause: Cause.Cause<E> ): Failure<E> => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype)
export const fail = <E, A = never>( export const willFetch = <R extends Final<any, any, any>>(
cause: Cause.Cause<E>, result: R
previousSuccess?: Success<NoInfer<A>>, ): Omit<R, keyof Flags.Keys> & WillFetch => Object.setPrototypeOf(
): Failure<A, E> => Object.setPrototypeOf({ Object.assign({}, result, { _flag: "WillFetch" }),
_tag: "Failure", Object.getPrototypeOf(result),
cause, )
previousSuccess: Option.fromNullable(previousSuccess),
}, ResultPrototype)
export const refreshing = <R extends Success<any> | Failure<any, any>, P = never>( export const willRefresh = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof Flags.Keys> & WillRefresh => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillRefresh" }),
Object.getPrototypeOf(result),
)
export const refreshing = <R extends Final<any, any, any>, P = never>(
result: R, result: R,
progress?: P, progress?: P,
): Omit<R, keyof Refreshing<Result.Progress<R>>> & Refreshing<P> => Object.setPrototypeOf( ): Omit<R, keyof Flags.Keys> & Refreshing<P> => Object.setPrototypeOf(
Object.assign({}, result, { refreshing: true, progress }), Object.assign({}, result, { _flag: "Refreshing", progress }),
Object.getPrototypeOf(result), Object.getPrototypeOf(result),
) )
export const optimistic = <R extends Success<any> | Failure<any, any>>( export const fromExit: {
result: R <A, E>(exit: Exit.Success<A, E>): Success<A>
): Omit<R, keyof Optimistic> & Optimistic => Object.setPrototypeOf( <A, E>(exit: Exit.Failure<A, E>): Failure<E>
Object.assign({}, result, { optimistic: true }), <A, E>(exit: Exit.Exit<A, E>): Success<A> | Failure<E>
Object.getPrototypeOf(result), } = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any
)
export const fromExit = <A, E>( export const toExit: {
exit: Exit.Exit<A, E>, <A>(self: Success<A>): Exit.Success<A, never>
previousSuccess?: Success<NoInfer<A>>, <E>(self: Failure<E>): Exit.Failure<never, E>
): Success<A> | Failure<A, E> => exit._tag === "Success" <A, E, P>(self: Final<A, E, P>): Exit.Exit<A, E>
? succeed(exit.value) <A, E, P>(self: Result<A, E, P>): Exit.Exit<A, E | Cause.NoSuchElementException>
: fail(exit.cause, previousSuccess) } = <A, E, P>(self: Result<A, E, P>): any => {
export const toExit = <A, E, P>(
self: Result<A, E, P>
): Exit.Exit<A, E | Cause.NoSuchElementException> => {
switch (self._tag) { switch (self._tag) {
case "Success": case "Success":
return Exit.succeed(self.value) return Exit.succeed(self.value)
@@ -193,17 +189,17 @@ export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
const state = yield* State<A, E, P>() const state = yield* State<A, E, P>()
return { return {
update: <E, R>(f: (previous: P) => Effect.Effect<P, E, R>) => Effect.Do.pipe( update: <FE, FR>(f: (previous: P) => Effect.Effect<P, FE, FR>) => Effect.Do.pipe(
Effect.bind("previous", () => Effect.andThen(state.get, previous => Effect.bind("previous", () => Effect.andThen(state.get, previous =>
isRunning(previous) || isRefreshing(previous) (isRunning(previous) || hasRefreshingFlag(previous))
? Effect.succeed(previous) ? Effect.succeed(previous)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })), : Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })),
)), )),
Effect.bind("progress", ({ previous }) => f(previous.progress)), Effect.bind("progress", ({ previous }) => f(previous.progress)),
Effect.let("next", ({ previous, progress }) => Object.setPrototypeOf( Effect.let("next", ({ previous, progress }) => isRunning(previous)
Object.assign({}, previous, { progress }), ? running(progress)
Object.getPrototypeOf(previous), : refreshing(previous, progress) as Final<A, E, P> & Refreshing<P>
)), ),
Effect.andThen(({ next }) => state.set(next)), Effect.andThen(({ next }) => state.set(next)),
), ),
} }
@@ -213,18 +209,10 @@ export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
export namespace unsafeForkEffect { export namespace unsafeForkEffect {
export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>> export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>>
export type Options<A, E, P> = { export interface Options<A, E, P> {
readonly initial?: Initial | Final<A, E, P>
readonly initialProgress?: P readonly initialProgress?: P
readonly previous?: Final<A, E, P>
} & (
| {
readonly refresh: true
readonly previous: Final<A, E, P>
} }
| {
readonly refresh?: false
}
)
} }
export const unsafeForkEffect = <A, E, R, P = never>( export const unsafeForkEffect = <A, E, R, P = never>(
@@ -235,16 +223,17 @@ export const unsafeForkEffect = <A, E, R, P = never>(
never, never,
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P> Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
> => Effect.Do.pipe( > => Effect.Do.pipe(
Effect.bind("ref", () => Ref.make(initial<A, E, P>())), Effect.bind("ref", () => Ref.make(options?.initial ?? initial<A, E, P>())),
Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()), Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()),
Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe( Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe(
Effect.andThen(state => state.set(options?.refresh Effect.andThen(state => state.set(
? refreshing(options.previous, options?.initialProgress) as Result<A, E, P> (isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress) : running(options?.initialProgress)
).pipe( ).pipe(
Effect.andThen(effect), Effect.andThen(effect),
Effect.onExit(exit => Effect.andThen( Effect.onExit(exit => Effect.andThen(
state.set(fromExit(exit, (options?.previous && isSuccess(options.previous)) ? options.previous : undefined)), state.set(fromExit(exit)),
Effect.forkScoped(PubSub.shutdown(pubsub)), Effect.forkScoped(PubSub.shutdown(pubsub)),
)), )),
)), )),
@@ -275,7 +264,7 @@ export const unsafeForkEffect = <A, E, R, P = never>(
export namespace forkEffect { export namespace forkEffect {
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P> export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P>
export type Options<A, E, P> = unsafeForkEffect.Options<A, E, P> export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
} }
export const forkEffect: { export const forkEffect: {

View File

@@ -18,16 +18,16 @@ const ResultView = Component.makeUntraced("Result")(function*() {
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idRef = yield* SubscriptionRef.make(1) const idRef = yield* SubscriptionRef.make(1)
const key = Stream.zipLatest(Stream.make("posts" as const), idRef.changes)
const query = yield* Query.service({ const query = yield* Query.service({
key, key: Stream.zipLatest(Stream.make("posts" as const), idRef.changes),
f: ([, id]) => HttpClient.HttpClient.pipe( f: ([, id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")), Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json), Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)), Effect.andThen(Schema.decodeUnknown(Post)),
), ),
staleTime: "10 seconds",
}) })
const mutation = yield* Mutation.make({ const mutation = yield* Mutation.make({
@@ -74,7 +74,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
Match.tag("Success", result => <> Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading> <Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text> <Text>{result.value.body}</Text>
{Result.isRefreshing(result) && <Text>Refreshing...</Text>} {Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>), </>),
Match.tag("Failure", result => Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text> <Text>An error has occured: {result.cause.toString()}</Text>
@@ -85,7 +85,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
<Flex direction="row" justify="center" align="center" gap="1"> <Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button> <Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
<Button onClick={() => runPromise(query.refetch)}>Refetch</Button> <Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
</Flex> </Flex>
<div> <div>
@@ -94,7 +94,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
Match.tag("Success", result => <> Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading> <Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text> <Text>{result.value.body}</Text>
{Result.isRefreshing(result) && <Text>Refreshing...</Text>} {Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>), </>),
Match.tag("Failure", result => Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text> <Text>An error has occured: {result.cause.toString()}</Text>