From 9c03d9999849bd6030436447058c0ab2606bfc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sat, 28 Feb 2026 20:55:42 +0100 Subject: [PATCH] Refactor Query --- bun.lock | 9 + packages/effect-fc/package.json | 1 + packages/effect-fc/src/Query.ts | 81 +++++---- packages/effect-fc/src/Result.ts | 280 +------------------------------ 4 files changed, 58 insertions(+), 313 deletions(-) diff --git a/bun.lock b/bun.lock index 32412cf..a94337a 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "@effect/platform-browser": "^0.74.0", }, "peerDependencies": { + "@effect-atom/atom": "^0.5.0", "@types/react": "^19.2.0", "effect": "^3.19.0", "react": "^19.2.0", @@ -114,14 +115,20 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], + "@effect-atom/atom": ["@effect-atom/atom@0.5.3", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "effect": "^3.19.15" } }, "sha512-TRZv/i+YT3TtnN0oFORJqXdxSs1fc7lrJlH+1xZvDFyjC9hgoVnrcKbeZsDFmr6r0wYRqVo7U3IftxiQNjpNZA=="], + "@effect-fc/example": ["@effect-fc/example@workspace:packages/example"], + "@effect/experimental": ["@effect/experimental@0.58.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA=="], + "@effect/language-service": ["@effect/language-service@0.75.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DxRN8+b5IEQ/x8hukpV39kJe7fs6er7LDWp1PvKjOxPkN5UJ8VJovUVzoHtOX6XWzMmJBRCN9/j0s8jujXTduw=="], "@effect/platform": ["@effect/platform@0.94.2", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.15" } }, "sha512-85vdwpnK4oH/rJ3EuX/Gi2Hkt+K4HvXWr9bxCuqvty9hxyEcRxkJcqTesYrcVoQB6aULb1Za2B0MKoTbvffB3Q=="], "@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=="], + "@effect/rpc": ["@effect/rpc@0.73.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.18" } }, "sha512-td7LHDgBOYKg+VgGWEelD8rSAmvjXz7am17vfxZROX5qIYuvH7drL/z4p5xQFadhHZ7DYdlFpqdO9ggc77OCIw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -634,6 +641,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], diff --git a/packages/effect-fc/package.json b/packages/effect-fc/package.json index c8429df..4566cd5 100644 --- a/packages/effect-fc/package.json +++ b/packages/effect-fc/package.json @@ -41,6 +41,7 @@ "@effect/platform-browser": "^0.74.0" }, "peerDependencies": { + "@effect-atom/atom": "^0.5.0", "@types/react": "^19.2.0", "effect": "^3.19.0", "react": "^19.2.0" diff --git a/packages/effect-fc/src/Query.ts b/packages/effect-fc/src/Query.ts index 6db26ea..0b68006 100644 --- a/packages/effect-fc/src/Query.ts +++ b/packages/effect-fc/src/Query.ts @@ -6,28 +6,27 @@ import * as Result from "./Result.js" export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query") export type QueryTypeId = typeof QueryTypeId -export interface Query +export interface Query extends Pipeable.Pipeable { readonly [QueryTypeId]: QueryTypeId readonly context: Context.Context readonly key: Stream.Stream readonly f: (key: K) => Effect.Effect - readonly initialProgress: P readonly staleTime: Duration.DurationInput readonly refreshOnWindowFocus: boolean readonly latestKey: Subscribable.Subscribable> readonly fiber: Subscribable.Subscribable>> - readonly result: Subscribable.Subscribable> - readonly latestFinalResult: Subscribable.Subscribable>> + readonly result: Subscribable.Subscribable> + readonly latestFinalResult: Subscribable.Subscribable | Result.Failure>> readonly run: Effect.Effect - fetch(key: K): Effect.Effect> - fetchSubscribable(key: K): Effect.Effect>> - readonly refresh: Effect.Effect, Cause.NoSuchElementException> - readonly refreshSubscribable: Effect.Effect>, Cause.NoSuchElementException> + fetch(key: K): Effect.Effect | Result.Failure> + fetchSubscribable(key: K): Effect.Effect>> + readonly refresh: Effect.Effect | Result.Failure, Cause.NoSuchElementException> + readonly refreshSubscribable: Effect.Effect>, Cause.NoSuchElementException> readonly invalidateCache: Effect.Effect invalidateCacheEntry(key: K): Effect.Effect @@ -37,23 +36,22 @@ export declare namespace Query { export type AnyKey = readonly any[] } -export class QueryImpl -extends Pipeable.Class() implements Query { +export class QueryImpl +extends Pipeable.Class() implements Query { readonly [QueryTypeId]: QueryTypeId = QueryTypeId constructor( readonly context: Context.Context, readonly key: Stream.Stream, readonly f: (key: K) => Effect.Effect, - readonly initialProgress: P, readonly staleTime: Duration.DurationInput, readonly refreshOnWindowFocus: boolean, readonly latestKey: SubscriptionRef.SubscriptionRef>, readonly fiber: SubscriptionRef.SubscriptionRef>>, - readonly result: SubscriptionRef.SubscriptionRef>, - readonly latestFinalResult: SubscriptionRef.SubscriptionRef>>, + readonly result: SubscriptionRef.SubscriptionRef | Result.Failure>, + readonly latestFinalResult: SubscriptionRef.SubscriptionRef | Result.Failure>>, readonly runSemaphore: Effect.Semaphore, ) { @@ -88,7 +86,7 @@ extends Pipeable.Class() implements Query { })) } - fetch(key: K): Effect.Effect> { + fetch(key: K): Effect.Effect | Result.Failure> { return this.interrupt.pipe( Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))), Effect.andThen(this.latestFinalResult), @@ -152,7 +150,7 @@ extends Pipeable.Class() implements Query { startCached( key: K, - initial: Result.Initial | Result.Final, + previous: Result.Success | Result.Failure, ): Effect.Effect< Subscribable.Subscribable>, never, @@ -174,31 +172,46 @@ extends Pipeable.Class() implements Query { start( key: K, - initial: Result.Initial | Result.Final, + previous: Result.Success | Result.Failure, ): Effect.Effect< - Subscribable.Subscribable>, + Subscribable.Subscribable>, never, Scope.Scope | R > { - return Result.unsafeForkEffect( - Effect.onExit(this.f(key), () => Effect.andThen( - Effect.all([Effect.fiberId, this.fiber]), - ([currentFiberId, fiber]) => Option.match(fiber, { - onSome: v => Equal.equals(currentFiberId, v.id()) - ? SubscriptionRef.set(this.fiber, Option.none()) - : Effect.void, - onNone: () => Effect.void, - }), - )), + return Effect.Do.pipe( + Effect.bind("ref", () => SubscriptionRef.make>(Result.initial())), - { - initial, - initialProgress: this.initialProgress, - } as Result.unsafeForkEffect.Options, - ).pipe( - Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))), - Effect.map(([sub]) => sub), ) + + Effect.onExit(this.f(key), () => Effect.andThen( + Effect.all([Effect.fiberId, this.fiber]), + ([currentFiberId, fiber]) => Option.match(fiber, { + onSome: v => Equal.equals(currentFiberId, v.id()) + ? SubscriptionRef.set(this.fiber, Option.none()) + : Effect.void, + onNone: () => Effect.void, + }), + )) + + // return Result.unsafeForkEffect( + // Effect.onExit(this.f(key), () => Effect.andThen( + // Effect.all([Effect.fiberId, this.fiber]), + // ([currentFiberId, fiber]) => Option.match(fiber, { + // onSome: v => Equal.equals(currentFiberId, v.id()) + // ? SubscriptionRef.set(this.fiber, Option.none()) + // : Effect.void, + // onNone: () => Effect.void, + // }), + // )), + + // { + // initial, + // initialProgress: this.initialProgress, + // } as Result.unsafeForkEffect.Options, + // ).pipe( + // Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))), + // Effect.map(([sub]) => sub), + // ) } watch( diff --git a/packages/effect-fc/src/Result.ts b/packages/effect-fc/src/Result.ts index 3cef66c..9cb91df 100644 --- a/packages/effect-fc/src/Result.ts +++ b/packages/effect-fc/src/Result.ts @@ -1,279 +1 @@ -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 type ResultTypeId = typeof ResultTypeId - -export type Result = ( - | Initial - | Running

- | Final -) - -// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here -export type Final = (Success | Failure) & ({} | Flags

) -export type Flags

= WillFetch | WillRefresh | Refreshing

- -export declare namespace Result { - export interface Prototype extends Pipeable.Pipeable, Equal.Equal { - readonly [ResultTypeId]: ResultTypeId - } - - export type Success> = [R] extends [Result] ? A : never - export type Failure> = [R] extends [Result] ? E : never - export type Progress> = [R] extends [Result] ? P : never -} - -export declare namespace Flags { - export type Keys = keyof WillFetch & WillRefresh & Refreshing -} - -export interface Initial extends Result.Prototype { - readonly _tag: "Initial" -} - -export interface Running

extends Result.Prototype { - readonly _tag: "Running" - readonly progress: P -} - -export interface Success extends Result.Prototype { - readonly _tag: "Success" - readonly value: A -} - -export interface Failure extends Result.Prototype { - readonly _tag: "Failure" - readonly cause: Cause.Cause -} - -export interface WillFetch { - readonly _flag: "WillFetch" -} - -export interface WillRefresh { - readonly _flag: "WillRefresh" -} - -export interface Refreshing

{ - readonly _flag: "Refreshing" - readonly progress: P -} - - -const ResultPrototype = Object.freeze({ - ...Pipeable.Prototype, - [ResultTypeId]: ResultTypeId, - - [Equal.symbol](this: Result, that: Result): boolean { - 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).progress)) - return false - return Match.value(this).pipe( - Match.tag("Initial", () => true), - Match.tag("Running", self => Equal.equals(self.progress, (that as Running).progress)), - Match.tag("Success", self => Equal.equals(self.value, (that as Success).value)), - Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure).cause)), - Match.exhaustive, - ) - }, - - [Hash.symbol](this: Result): number { - return pipe(Hash.string(this._tag), - tagHash => Match.value(this).pipe( - Match.tag("Initial", () => tagHash), - Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)), - Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)), - Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)), - Match.exhaustive, - ), - Hash.combine(Hash.hash((this as Flags)._flag)), - hash => hasRefreshingFlag(this) - ? Hash.combine(Hash.hash(this.progress))(hash) - : hash, - Hash.cached(this), - ) - }, -} as const satisfies Result.Prototype) - - -export const isResult = (u: unknown): u is Result => Predicate.hasProperty(u, ResultTypeId) -export const isFinal = (u: unknown): u is Final => isResult(u) && (isSuccess(u) || isFailure(u)) -export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial" -export const isRunning = (u: unknown): u is Running => isResult(u) && u._tag === "Running" -export const isSuccess = (u: unknown): u is Success => isResult(u) && u._tag === "Success" -export const isFailure = (u: unknown): u is Failure => isResult(u) && u._tag === "Failure" -export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag") -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 => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing" - -export const initial: { - (): Initial - (): Result -} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype) -export const running =

(progress?: P): Running

=> Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype) -export const succeed = (value: A): Success => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype) -export const fail = (cause: Cause.Cause ): Failure => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype) - -export const willFetch = >( - result: R -): Omit & WillFetch => Object.setPrototypeOf( - Object.assign({}, result, { _flag: "WillFetch" }), - Object.getPrototypeOf(result), -) - -export const willRefresh = >( - result: R -): Omit & WillRefresh => Object.setPrototypeOf( - Object.assign({}, result, { _flag: "WillRefresh" }), - Object.getPrototypeOf(result), -) - -export const refreshing = , P = never>( - result: R, - progress?: P, -): Omit & Refreshing

=> Object.setPrototypeOf( - Object.assign({}, result, { _flag: "Refreshing", progress }), - Object.getPrototypeOf(result), -) - -export const fromExit: { - (exit: Exit.Success): Success - (exit: Exit.Failure): Failure - (exit: Exit.Exit): Success | Failure -} = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any - -export const toExit: { - (self: Success): Exit.Success - (self: Failure): Exit.Failure - (self: Final): Exit.Exit - (self: Result): Exit.Exit -} = (self: Result): any => { - switch (self._tag) { - case "Success": - return Exit.succeed(self.value) - case "Failure": - return Exit.failCause(self.cause) - default: - return Exit.fail(new Cause.NoSuchElementException()) - } -} - - -export interface State { - readonly get: Effect.Effect> - readonly set: (v: Result) => Effect.Effect -} - -export const State = (): Context.Tag, State> => Context.GenericTag("@effect-fc/Result/State") - -export interface Progress

{ - readonly update: ( - f: (previous: P) => Effect.Effect - ) => Effect.Effect -} - -export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{ - readonly previous: Result -}> {} - -export const Progress =

(): Context.Tag, Progress

> => Context.GenericTag("@effect-fc/Result/Progress") - -export const makeProgressLayer = (): Layer.Layer< - Progress

, - never, - State -> => Layer.effect(Progress

(), Effect.gen(function*() { - const state = yield* State() - - return { - update: (f: (previous: P) => Effect.Effect) => Effect.Do.pipe( - Effect.bind("previous", () => Effect.andThen(state.get, previous => - (isRunning(previous) || hasRefreshingFlag(previous)) - ? Effect.succeed(previous) - : Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })), - )), - Effect.bind("progress", ({ previous }) => f(previous.progress)), - Effect.let("next", ({ previous, progress }) => isRunning(previous) - ? running(progress) - : refreshing(previous, progress) as Final & Refreshing

- ), - Effect.andThen(({ next }) => state.set(next)), - ), - } -})) - - -export namespace unsafeForkEffect { - export type OutputContext = Exclude | Progress

| Progress> - - export interface Options { - readonly initial?: Initial | Final - readonly initialProgress?: P - } -} - -export const unsafeForkEffect = ( - effect: Effect.Effect, - options?: unsafeForkEffect.Options, NoInfer, P>, -): Effect.Effect< - readonly [result: Subscribable.Subscribable, never, never>, fiber: Fiber.Fiber], - never, - Scope.Scope | unsafeForkEffect.OutputContext -> => Effect.Do.pipe( - Effect.bind("ref", () => Ref.make(options?.initial ?? initial())), - Effect.bind("pubsub", () => PubSub.unbounded>()), - Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State().pipe( - Effect.andThen(state => state.set( - (isFinal(options?.initial) && hasWillRefreshFlag(options?.initial)) - ? refreshing(options.initial, options?.initialProgress) as Result - : running(options?.initialProgress) - ).pipe( - Effect.andThen(effect), - Effect.onExit(exit => Effect.andThen( - state.set(fromExit(exit)), - Effect.forkScoped(PubSub.shutdown(pubsub)), - )), - )), - Effect.provide(Layer.empty.pipe( - Layer.provideMerge(makeProgressLayer()), - Layer.provideMerge(Layer.succeed(State(), { - get: ref, - set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v)) - })), - )), - ))), - Effect.map(({ ref, pubsub, fiber }) => [ - Subscribable.make({ - get: ref, - changes: Stream.unwrapScoped(Effect.map( - Effect.all([ref, Stream.fromPubSub(pubsub, { scoped: true })]), - ([latest, stream]) => Stream.concat(Stream.make(latest), stream), - )), - }), - fiber, - ]), -) as Effect.Effect< - readonly [result: Subscribable.Subscribable, never, never>, fiber: Fiber.Fiber], - never, - Scope.Scope | unsafeForkEffect.OutputContext -> - -export namespace forkEffect { - export type InputContext = R extends Progress ? [X] extends [P] ? R : never : R - export type OutputContext = unsafeForkEffect.OutputContext - export interface Options extends unsafeForkEffect.Options {} -} - -export const forkEffect: { - ( - effect: Effect.Effect>>, - options?: forkEffect.Options, NoInfer, P>, - ): Effect.Effect< - readonly [result: Subscribable.Subscribable, never, never>, fiber: Fiber.Fiber], - never, - Scope.Scope | forkEffect.OutputContext - > -} = unsafeForkEffect +export * from "@effect-atom/atom/Result"