import { type Update } from "common"; import { Data, Deferred, Effect, Exit, Option } from "effect"; import { dual, identity } from "effect/Function"; export class InvalidModificationError extends Data.TaggedError("InvalidModificationError")<{ key: K }> { } export type FetchFn = (key: K) => Effect.Effect; export type ListenFn = (state: Option.Option>) => void; export interface Pending { readonly _tag: "Pending"; readonly deferred: Deferred.Deferred; } export interface Fulfilled { readonly _tag: "Fulfilled"; readonly exit: Exit.Exit; } export type State = | Pending | Fulfilled; export const Match = dual< (options: { readonly onPending: (deferred: Deferred.Deferred) => Z1, readonly onFulfilled: (exit: Exit.Exit) => Z2, }) => (self: State) => Z1 | Z2, (self: State, options: { readonly onPending: (deferred: Deferred.Deferred) => Z1, readonly onFulfilled: (exit: Exit.Exit) => Z2, }) => Z1 | Z2 >(2, (self, { onPending, onFulfilled }) => { switch (self._tag) { case "Pending": return onPending(self.deferred); case "Fulfilled": return onFulfilled(self.exit); } }); export const Pending = (deferred: Deferred.Deferred): Pending => Object.freeze>({ _tag: "Pending", deferred, }); export const Fulfilled = (exit: Exit.Exit): Fulfilled => Object.freeze>({ _tag: "Fulfilled", exit, }); declare const CacheTypeId: unique symbol; type CacheTypeId = typeof CacheTypeId; export interface Cache extends CacheInterface { readonly [CacheTypeId]: { readonly _K: (_: K) => K, readonly _A: (_: A) => A, readonly _E: (_: E) => E, }; } interface CacheInterface { /** * Save value for a new key to the cache. Running this effect while the key * already exists in the cache is not allowed and results in a * `InvalidModificationError`. */ readonly create: (key: K, value: A) => Effect.Effect>; /** * Call `fetchFn`, save the result to the cache and return the result. */ readonly refresh: (key: K) => Effect.Effect; /** * If the `key` exists in the cache, retrieve the result, otherwise call * `fetchFn`, save the result to the cache and return the result. */ readonly get: (key: K) => Effect.Effect; /** * Retrieve the state currently stored in the cache for a given `key`. */ readonly getCurrent: (key: K) => State | undefined; /** * Set or update the value currently stored in the cache for a given `key` * and return the updated value. Running this effect while the state is * pending or the key does not exist in the cache is not allowed and results * in a `InvalidModificationError`. Running this effect while the state * is erroneously fulfilled will do nothing and return the error. */ readonly update: (key: K, action: Update) => Effect.Effect>; /** * Remove the state currently stored in the cache for a given `key`. Running * this effect while the key does not exist in the cache is not allowed and * results in a `InvalidModificationError`. */ readonly delete: (key: K) => Effect.Effect>; /** * Subscribe to any change in the internal cache to a given key. * @returns Unsubscribe function */ readonly subscribe: (key: K, callback: ListenFn) => (() => void); } export type Key> = T extends Cache ? K : never; export type Value> = T extends Cache ? A : never; export type Error> = T extends Cache ? E : never; export const make = (fetchFn: FetchFn): Cache => { const stateMap = new Map>; const listenersMap = new Map>>(); // --- INTERNAL FUNCTIONS -------------------------------------------------- const state_setPending = (key: K, deferred: Deferred.Deferred) => { const pending = Pending(deferred); stateMap.set(key, pending); listenersMap.get(key)?.forEach((callback) => callback(Option.some(pending))); }; const state_setFulfilled = (key: K, exit: Exit.Exit) => { const fulfilled = Fulfilled(exit); stateMap.set(key, fulfilled); listenersMap.get(key)?.forEach((callback) => callback(Option.some(fulfilled))); }; const state_delete = (key: K) => { stateMap.delete(key); listenersMap.get(key)?.forEach((callback) => callback(Option.none())); }; // --- INTERFACE ----------------------------------------------------------- const create = (key: K, value: A) => Effect.suspend(() => { if (stateMap.has(key)) { return Effect.fail(new InvalidModificationError({ key })); } state_setFulfilled(key, Exit.succeed(value)); return Effect.succeed(value); }); const refresh = (key: K) => Effect.gen(function* () { const deferred = yield* Deferred.make(); state_setPending(key, deferred); const exit = yield* Effect.exit(fetchFn(key)); state_setFulfilled(key, exit); yield* Deferred.done(deferred, exit); return yield* exit; }).pipe(Effect.uninterruptible); const get = (key: K) => Effect.suspend(() => { const state = stateMap.get(key); if (state === undefined) { return refresh(key); } return Match(state, { onPending: Deferred.await, onFulfilled: identity, }); }); const getCurrent = (key: K) => stateMap.get(key); const update = (key: K, action: Update) => Effect.suspend(() => { const state = stateMap.get(key); if (state === undefined) { return Effect.fail(new InvalidModificationError({ key })); } return Match(state, { onPending: () => Effect.fail(new InvalidModificationError({ key })), onFulfilled: Exit.match({ onFailure: () => Effect.fail(new InvalidModificationError({ key })), onSuccess: (value) => { const nextValue = typeof action === "function" ? (action as (prev: A) => A)(value) : action; state_setFulfilled(key, Exit.succeed(nextValue)); return Effect.succeed(nextValue); }, }), }); }); const _delete = (key: K) => Effect.suspend(() => { if (!stateMap.has(key)) { return Effect.fail(new InvalidModificationError({ key })); } state_delete(key); return Effect.void; }); const subscribe = (key: K, callback: ListenFn) => { let listeners = listenersMap.get(key); if (listeners === undefined) { listeners = new Set(); listenersMap.set(key, listeners); } listeners.add(callback); return () => { listenersMap.get(key)?.delete(callback); }; }; const cacheInterface = Object.freeze>({ create, refresh, get, getCurrent, update, delete: _delete, subscribe, }); return cacheInterface as Cache; };