diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml index f553df5..3625031 100644 --- a/.gitea/workflows/publish.yaml +++ b/.gitea/workflows/publish.yaml @@ -40,6 +40,13 @@ jobs: access: public token: ${{ secrets.NPM_TOKEN }} registry: https://registry.npmjs.org + - name: Publish @effect-view/vite-plugin + uses: JS-DevTools/npm-publish@v4 + with: + package: packages/vite-plugin + access: public + token: ${{ secrets.NPM_TOKEN }} + registry: https://registry.npmjs.org - name: Publish effect-fc uses: JS-DevTools/npm-publish@v4 with: diff --git a/packages/docs/docs/query.md b/packages/docs/docs/query.md index ccb4efc..b5864a6 100644 --- a/packages/docs/docs/query.md +++ b/packages/docs/docs/query.md @@ -248,6 +248,33 @@ workflows that need to wait for the final success or failure state. Invalidating does not itself refetch. Follow it with `refreshView`, change the key, or allow a later fetch to repopulate the cache. +### Refresh on an interval + +Refresh every five minutes, starting after five minutes: + +```ts +import { Schedule } from "effect" + +yield* query.pipe( + Query.withScheduledRefresh(Schedule.spaced("5 minutes")), +) +``` + +Limit the number of refreshes: + +```ts +yield* query.pipe( + Query.withScheduledRefresh( + Schedule.spaced("5 minutes").pipe( + Schedule.upTo({ times: 3 }), + ), + ), +) +``` + +The refresh fiber stops with the surrounding scope. The Effect returns the +original query. Cache and `staleTime` rules still apply. + ## Staleness and cache lifetime `staleTime` controls how long a successful result can satisfy a fetch without diff --git a/packages/effect-view/src/Query.test.ts b/packages/effect-view/src/Query.test.ts index b518c0d..2a50873 100644 --- a/packages/effect-view/src/Query.test.ts +++ b/packages/effect-view/src/Query.test.ts @@ -1,4 +1,5 @@ -import { Effect, type Scope, Stream } from "effect" +import { Effect, Schedule, type Scope, Stream } from "effect" +import { TestClock } from "effect/testing" import { AsyncResult } from "effect/unstable/reactivity" import { describe, expect, it } from "vitest" import * as Query from "./Query.js" @@ -79,6 +80,49 @@ describe("Query", () => { expect(expectSuccessValue(result[1])).toBe("value:1:2") }) + it("withScheduledRefresh lets the Schedule control the first refresh", async () => { + let calls = 0 + const key = staticKey([1]) + + const result = await runQueryTest(Effect.gen(function*() { + const query = yield* Query.make({ + key, + f: () => Effect.sync(() => { + calls += 1 + return calls + }), + staleTime: "0 millis", + }) + + yield* query.fetch([1]) + const returnedQuery = yield* query.pipe(Query.withScheduledRefresh( + Schedule.spaced("1 second").pipe( + Schedule.upTo({ times: 1 }), + ), + )) + + yield* TestClock.adjust("999 millis") + const beforeInterval = calls + + yield* TestClock.adjust("1 millis") + const afterFirstInterval = calls + + return { + returnsQuery: returnedQuery === query, + beforeInterval, + afterFirstInterval, + } + }).pipe( + Effect.provide(TestClock.layer()), + )) + + expect(result).toEqual({ + returnsQuery: true, + beforeInterval: 1, + afterFirstInterval: 2, + }) + }) + it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => { let calls = 0 const key = staticKey([1]) diff --git a/packages/effect-view/src/Query.ts b/packages/effect-view/src/Query.ts index e03bce7..f170cab 100644 --- a/packages/effect-view/src/Query.ts +++ b/packages/effect-view/src/Query.ts @@ -1,4 +1,4 @@ -import { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Option, Pipeable, Predicate, PubSub, Ref, type Scope, Semaphore, Stream, SubscriptionRef } from "effect" +import { Cause, type Context, Duration, Effect, Equal, type Equivalence, Exit, Fiber, Function, Option, Pipeable, Predicate, PubSub, Ref, type Schedule, type Scope, Semaphore, Stream, SubscriptionRef } from "effect" import { AsyncResult } from "effect/unstable/reactivity" import * as Lens from "./Lens.js" import * as QueryClient from "./QueryClient.js" @@ -421,6 +421,43 @@ export const service = ( query => Effect.forkScoped(query.run), ) +/** + * Refreshes the Query on a schedule and returns it. + * + * @example Refresh every five minutes, starting after five minutes + * ```ts + * yield* query.pipe( + * Query.withScheduledRefresh(Schedule.spaced("5 minutes")), + * ) + * ``` + * + * @example Refresh at most three times + * ```ts + * yield* query.pipe( + * Query.withScheduledRefresh( + * Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })), + * ), + * ) + * ``` + */ +export const withScheduledRefresh: { + ( + schedule: Schedule.Schedule, + ): ( + self: Query, + ) => Effect.Effect, never, Scope.Scope | Env> + ( + self: Query, + schedule: Schedule.Schedule, + ): Effect.Effect, never, Scope.Scope | Env> +} = Function.dual(2, ( + self: Query, + schedule: Schedule.Schedule, +) => Effect.schedule(self.refresh, schedule).pipe( + Effect.forkScoped, + Effect.as(self), +)) + export class QueryStateLens extends Lens.LensImpl, never, never, never, never> {