From 6ea21aa0f1706f26be1976316695ba1447d3eda3 Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Fri, 10 Oct 2025 14:54:42 +0200 Subject: [PATCH] Reimplement cache more lol --- packages/common/src/Cache.ts | 191 +++++++++++++------ packages/common/src/index.ts | 5 +- packages/frontend/src/hooks/useCache.ts | 63 +++--- packages/frontend/src/hooks/useStore.ts | 6 +- packages/frontend/src/routes/Piece.tsx | 34 ++-- packages/frontend/src/routes/Pieces.tsx | 16 +- packages/frontend/src/routes/Repertoire.tsx | 23 ++- packages/frontend/src/routes/Repertoires.tsx | 16 +- 8 files changed, 224 insertions(+), 130 deletions(-) diff --git a/packages/common/src/Cache.ts b/packages/common/src/Cache.ts index 15cfb09..2690e7a 100644 --- a/packages/common/src/Cache.ts +++ b/packages/common/src/Cache.ts @@ -1,7 +1,11 @@ -import { Deferred, Effect, Exit, flow, HashMap, Option } from "effect"; +import { 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: Map) => void; +export type ListenFn = (state: Option.Option>) => void; export interface Pending { readonly _tag: "Pending"; @@ -17,6 +21,24 @@ 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, @@ -39,109 +61,158 @@ export interface Cache extends CacheInterface { } interface CacheInterface { - readonly setPending: (key: K, deferred: Deferred.Deferred) => void; - readonly setFulfilled: (key: K, exit: Exit.Exit) => void; - readonly setFulfilledSucceed: (key: K, value: A) => void; - readonly unset: (key: K) => void; - readonly update: (key: K, action: (prev: A) => A) => void; + /** + * 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) => Option.Option>; - readonly subscribe: (callback: ListenFn) => (() => void); + /** + * 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 type Map = HashMap.HashMap>; - export const make = (fetchFn: FetchFn): Cache => { - let map: Map = HashMap.empty(); + const stateMap = new Map>; + const listenersMap = new Map>>(); - const listeners = new Set>(); + // --- INTERNAL FUNCTIONS -------------------------------------------------- - const setPending = (key: K, deferred: Deferred.Deferred) => { + const state_setPending = (key: K, deferred: Deferred.Deferred) => { const pending = Pending(deferred); - map = HashMap.set(map, key, pending); - listeners.forEach((callback) => callback(map)); + stateMap.set(key, pending); + listenersMap.get(key)?.forEach((callback) => callback(Option.some(pending))); }; - const setFulfilled = (key: K, exit: Exit.Exit) => { + const state_setFulfilled = (key: K, exit: Exit.Exit) => { const fulfilled = Fulfilled(exit); - map = HashMap.set(map, key, fulfilled); - listeners.forEach((callback) => callback(map)); + stateMap.set(key, fulfilled); + listenersMap.get(key)?.forEach((callback) => callback(Option.some(fulfilled))); }; - const setFulfilledSucceed = (key: K, value: A) => { - setFulfilled(key, Exit.succeed(value)); + const state_delete = (key: K) => { + stateMap.delete(key); + listenersMap.get(key)?.forEach((callback) => callback(Option.none())); }; - const unset = (key: K) => { - map = HashMap.remove(map, key); - listeners.forEach((callback) => callback(map)); - }; + // --- INTERFACE ----------------------------------------------------------- - const update = (key: K, update: (prev: A) => A) => { - map = HashMap.modify(map, key, (state) => { - switch (state._tag) { - case "Pending": - return state; - case "Fulfilled": - return Exit.match(state.exit, { - onFailure: () => state, - onSuccess: flow(update, Exit.succeed, Fulfilled), - }); - } - }); - listeners.forEach((callback) => callback(map)); - } + const create = (key: K, value: A) => Effect.suspend(() => { + if (stateMap.has(key)) { + return Effect.fail(new InvalidModificationError({ key })); + } - const refresh = (key: K): Effect.Effect => Effect.gen(function* () { + state_setFulfilled(key, Exit.succeed(value)); + return Effect.succeed(value); + }); + + const refresh = (key: K) => Effect.gen(function* () { const deferred = yield* Deferred.make(); - setPending(key, deferred); + state_setPending(key, deferred); const exit = yield* Effect.exit(fetchFn(key)); - setFulfilled(key, exit); + state_setFulfilled(key, exit); yield* Deferred.done(deferred, exit); return yield* exit; }).pipe(Effect.uninterruptible); - const get = (key: K): Effect.Effect => Effect.gen(function* () { - const state = Option.getOrNull(HashMap.get(map, key)); - - if (state === null) { - return yield* refresh(key); + const get = (key: K) => Effect.suspend(() => { + const state = stateMap.get(key); + if (state === undefined) { + return refresh(key); } - switch (state._tag) { - case "Pending": - return yield* Deferred.await(state.deferred); - case "Fulfilled": - return yield* state.exit; - } + return Match(state, { + onPending: Deferred.await, + onFulfilled: identity, + }); }); - const getCurrent = (key: K): Option.Option> => HashMap.get(map, key); + const getCurrent = (key: K) => Option.fromNullable(stateMap.get(key)); - const subscribe = (callback: ListenFn) => { + 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 () => { - listeners.delete(callback); + listenersMap.get(key)?.delete(callback); }; }; const cacheInterface = Object.freeze>({ - setPending, - setFulfilled, - setFulfilledSucceed, - unset, - update, + create, refresh, get, getCurrent, + update, + delete: _delete, subscribe, }); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index cf5ce7d..bf811fa 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,4 +1,7 @@ -import { Arbitrary, Array, Equal, Equivalence, pipe, Predicate, Pretty, Schema } from "effect"; +import { Arbitrary, Array, Equal, Equivalence, pipe, Pretty, Schema } from "effect"; + +export type Update = T | ((prev: T) => T); +export type Updater = (action: Update) => void; export const isUint8ArrayArrayBuffer = (input: unknown): input is Uint8Array => input instanceof Uint8Array && input.buffer instanceof ArrayBuffer; diff --git a/packages/frontend/src/hooks/useCache.ts b/packages/frontend/src/hooks/useCache.ts index 26f4c85..5213053 100644 --- a/packages/frontend/src/hooks/useCache.ts +++ b/packages/frontend/src/hooks/useCache.ts @@ -1,6 +1,7 @@ import * as Cache from "common/Cache"; import { Cause, Effect, Either, Exit, flow, Option } from "effect"; -import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { constant } from "effect/Function"; +import { useCallback, useEffect as useLayoutEffect, useMemo, useSyncExternalStore } from "react"; export namespace Loading { export interface Pending { @@ -34,39 +35,43 @@ const PENDING: Loading.Pending = Object.freeze({ error: null, }); -function resolveState(maybeState: Option.Option>): Loading { - const state = Option.getOrNull(maybeState); +const pending = constant(PENDING); - if (state === null) { - return PENDING; - } +const success = (data: A): Loading.Success => Object.freeze>({ + isLoading: false, + data, + error: null, +}); - switch (state._tag) { - case "Pending": - return PENDING; - case "Fulfilled": - return Exit.match(state.exit, { +const error = (error: E): Loading.Error => Object.freeze>({ + isLoading: false, + data: null, + error, +}); + +export function useCache(cache: Cache.Cache, key: NoInfer): Loading { + + const subscribe = useMemo(() => cache.subscribe.bind(undefined, key), [cache.subscribe, key]); + const selector = useCallback(() => cache.getCurrent(key), [cache.getCurrent, key]); + + const state = useSyncExternalStore(subscribe, selector); + + useLayoutEffect(() => { Effect.runFork(cache.get(key)); }, [cache.get, key]); + + return Option.match(state, { + onNone: pending, + onSome: Cache.Match({ + onPending: pending, + onFulfilled: Exit.match({ onFailure: flow( Cause.failureOrCause, Either.match({ - onLeft: (error) => Object.freeze>({ isLoading: false, data: null, error }), - onRight: (cause) => Object.freeze>({ isLoading: false, data: null, error: cause as any }), + onLeft: error, + onRight: (cause) => error(cause as any), }), ), - onSuccess: (data) => Object.freeze>({ isLoading: false, data, error: null }), - }); - } -} - -export function useCache(cache: Cache.Cache, key: NoInfer, refresh: boolean = false) { - - const selector = useCallback(() => cache.getCurrent(key), [cache.getCurrent, key]); - - const state = useSyncExternalStore(cache.subscribe, selector); - - useEffect(() => { - Effect.runFork(refresh ? cache.refresh(key) : cache.get(key)); - }, [cache.get, key, refresh]); - - return resolveState(state); + onSuccess: success, + }), + }), + }); } diff --git a/packages/frontend/src/hooks/useStore.ts b/packages/frontend/src/hooks/useStore.ts index 728719f..40fe611 100644 --- a/packages/frontend/src/hooks/useStore.ts +++ b/packages/frontend/src/hooks/useStore.ts @@ -1,10 +1,8 @@ -import { type User } from "common/the_api"; +import { Update } from "common"; +import { User } from "common/the_api"; import { identity } from "effect"; import { useLayoutEffect, useState } from "react"; -export type Update = T | ((prev: T) => T); -export type Updater = (action: Update) => void; - export const mapProp = (prop: K, action: Update) => (object: O): O => { return Object.freeze({ ...object, [prop]: typeof action === "function" ? (action as (prev: T) => T)(object[prop]) : action }); }; diff --git a/packages/frontend/src/routes/Piece.tsx b/packages/frontend/src/routes/Piece.tsx index 620b462..9388a4b 100644 --- a/packages/frontend/src/routes/Piece.tsx +++ b/packages/frontend/src/routes/Piece.tsx @@ -1,4 +1,4 @@ -import { Attachment, denormalizeSystemInformation, pieceCache, type Piece } from "@/cache"; +import { Attachment, denormalizePiece, denormalizeSystemInformation, pieceCache, type Piece } from "@/cache"; import { API_URL_PREFIX, client } from "@/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -78,13 +78,18 @@ function PieceForm(props: PieceForm.Props) { try { setIsSaving(true); - await client.updatePiece({ - pieceId: props.piece.pieceId, - name, - composer: composer.length > 0 ? Option.some(composer) : Option.none(), - lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(), - arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(), - }).pipe(Effect.runPromise); + await pipe( + client.updatePiece({ + pieceId: props.piece.pieceId, + name, + composer: composer.length > 0 ? Option.some(composer) : Option.none(), + lyricist: lyricist.length > 0 ? Option.some(lyricist) : Option.none(), + arranger: arranger.length > 0 ? Option.some(arranger) : Option.none(), + }), + Effect.flatMap(denormalizePiece), + Effect.tap((piece) => pieceCache.update(piece.pieceId, piece)), + Effect.runPromise, + ); } catch (error) { console.error(error); } finally { @@ -97,8 +102,11 @@ function PieceForm(props: PieceForm.Props) { try { setIsDeleting(true); - await Effect.runPromise(client.deletePiece(props.piece.pieceId)); - pieceCache.unset(props.piece.pieceId); + await pipe( + client.deletePiece(props.piece.pieceId), + Effect.andThen(pieceCache.delete(props.piece.pieceId)), + Effect.runPromise, + ); navigate(".."); } catch (error) { @@ -200,8 +208,6 @@ namespace AttachmentRow { function AttachmentRow(props: AttachmentRow.Props) { - const url = `${API_URL_PREFIX}/api/piece/${props.attachment.pieceId}/attachment/${props.attachment.attachmentId}`; - const download = () => Effect.gen(function* () { const { data, mediaType, filename } = yield* client.getAttachment(props.attachment.attachmentId); @@ -239,7 +245,7 @@ function AttachmentRow(props: AttachmentRow.Props) { const doDelete = () => Effect.gen(function* () { yield* client.deleteAttachment(props.attachment.attachmentId); - pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter((a) => a.attachmentId !== props.attachment.attachmentId))); + yield* pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter((a) => a.attachmentId !== props.attachment.attachmentId))); }).pipe(Effect.runPromise); return ( @@ -332,7 +338,7 @@ function AttachmentForm(props: AttachmentForm.Props) { const attachment = yield* denormalizeSystemInformation(exit.value); - pieceCache.update(props.pieceId, mapProp("attachments", (attachments: readonly Attachment[]) => { + yield* pieceCache.update(props.pieceId, mapProp("attachments", (attachments: readonly Attachment[]) => { const next = [...attachments, attachment]; next.sort((a, b) => a.filename.localeCompare(b.filename)); return next; diff --git a/packages/frontend/src/routes/Pieces.tsx b/packages/frontend/src/routes/Pieces.tsx index 46a4268..ace6c98 100644 --- a/packages/frontend/src/routes/Pieces.tsx +++ b/packages/frontend/src/routes/Pieces.tsx @@ -7,10 +7,9 @@ import { Label } from "@/components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { useCache } from "@/hooks/useCache"; import { useLoading } from "@/hooks/useLoading"; -import { Updater } from "@/hooks/useStore"; import { authors, created, DEBOUNCE, modified, SAVE_DELAY } from "@/snippets"; import clsx from "clsx"; -import { PieceId } from "common"; +import { PieceId, Updater } from "common"; import * as Body from "common/Body"; import { getMediaTypeForFilename } from "common/MediaType"; import { Array, Cause, Effect, Fiber, Iterable, Match, Option, Order, pipe, Predicate, Scope, SortedMap } from "effect"; @@ -27,7 +26,7 @@ export function Pieces() { const debounce = useRef(Effect.void); - const { isLoading, error, data: pieceIds } = useLoading(Effect.gen(function* () { + const { isLoading, error, data: pieceIds, refresh } = useLoading(Effect.gen(function* () { yield* debounce.current; const data = yield* client.queryPieces({ name: name !== "" ? Option.some(name) : Option.none(), @@ -55,7 +54,7 @@ export function Pieces() { Importuj utwory - + 0 ? Option.some(arranger) : Option.none(), }), Effect.flatMap(denormalizePiece), - Effect.tap((piece) => Effect.sync(() => pieceCache.setFulfilledSucceed(piece.pieceId, piece))), + Effect.tap((piece) => pieceCache.create(piece.pieceId, piece)), ); navigate(pieceId); @@ -260,6 +259,7 @@ function AddPieceDialogContent() { namespace ImportPiecesDialogContent { export interface Props { readonly setDialogOpen: Updater; + readonly refresh: Effect.Effect; } } @@ -358,12 +358,16 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) { yield* pipe( { ...piece, attachments }, denormalizePiece, - Effect.tap((piece) => Effect.sync(() => pieceCache.setFulfilledSucceed(piece.pieceId, piece))), + Effect.tap((piece) => pieceCache.create(piece.pieceId, piece)), ); + + setFiles(SortedMap.remove(name)); })), Effect.allWith({ concurrency: "unbounded" }), ); + yield* props.refresh; + props.setDialogOpen(false); })); }).pipe(Effect.runPromise); diff --git a/packages/frontend/src/routes/Repertoire.tsx b/packages/frontend/src/routes/Repertoire.tsx index 36b545c..dbc245f 100644 --- a/packages/frontend/src/routes/Repertoire.tsx +++ b/packages/frontend/src/routes/Repertoire.tsx @@ -7,9 +7,8 @@ import { Label } from "@/components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { useCache } from "@/hooks/useCache"; import { useLoading } from "@/hooks/useLoading"; -import { Updater } from "@/hooks/useStore"; import { authors, DEBOUNCE, SAVE_DELAY } from "@/snippets"; -import { PieceId, RepertoireId } from "common"; +import { PieceId, RepertoireId, Updater } from "common"; import { Array, Cause, Effect, Fiber, Match, Option, pipe, Scope } from "effect"; import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react"; import { FormEventHandler, useId, useRef, useState } from "react"; @@ -75,11 +74,15 @@ function RepertoireForm(props: RepertoireForm.Props) { setIsSaving(true); - yield* client.updateRepertoire({ - repertoireId: props.repertoire.repertoireId, - name, - entries: props.repertoire.entries.map(({ pieceId }) => pieceId), - }); + yield* pipe( + client.updateRepertoire({ + repertoireId: props.repertoire.repertoireId, + name, + entries: props.repertoire.entries.map(({ pieceId }) => pieceId), + }), + Effect.flatMap(denormalizeRepertoire), + Effect.tap((repertoire) => repertoireCache.update(repertoire.repertoireId, repertoire)), + ); })); }).pipe(Effect.runPromise); @@ -90,7 +93,7 @@ function RepertoireForm(props: RepertoireForm.Props) { setIsDeleting(true); yield* client.deleteRepertoire(props.repertoire.repertoireId); - repertoireCache.unset(props.repertoire.repertoireId); + yield* repertoireCache.delete(props.repertoire.repertoireId); navigate(".."); })).pipe(Effect.runPromise); @@ -210,7 +213,7 @@ function EntryRow({ entries: pipe(repertoire.entries, action, mapToId), }), Effect.flatMap(denormalizeRepertoire), - Effect.tap((repertoire) => Effect.sync(() => repertoireCache.setFulfilledSucceed(repertoire.repertoireId, repertoire))), + Effect.tap((repertoire) => repertoireCache.update(repertoire.repertoireId, repertoire)), ); }); @@ -360,7 +363,7 @@ function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) { entries: pipe(props.repertoire.entries, action, mapToId), }), Effect.flatMap(denormalizeRepertoire), - Effect.tap((repertoire) => Effect.sync(() => repertoireCache.setFulfilledSucceed(repertoire.repertoireId, repertoire))), + Effect.tap((repertoire) => repertoireCache.update(repertoire.repertoireId, repertoire)), ); props.setDialogOpen(false); diff --git a/packages/frontend/src/routes/Repertoires.tsx b/packages/frontend/src/routes/Repertoires.tsx index 1add3df..7f17a90 100644 --- a/packages/frontend/src/routes/Repertoires.tsx +++ b/packages/frontend/src/routes/Repertoires.tsx @@ -1,4 +1,4 @@ -import { repertoireCache } from "@/cache"; +import { denormalizeRepertoire, repertoireCache } from "@/cache"; import { client } from "@/client"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; @@ -9,7 +9,7 @@ import { useCache } from "@/hooks/useCache"; import { useLoading } from "@/hooks/useLoading"; import { created, DEBOUNCE, modified } from "@/snippets"; import { RepertoireId } from "common"; -import { Cause, Effect, Match, Option, Scope } from "effect"; +import { Cause, Effect, Match, Option, pipe, Scope } from "effect"; import { Loader2, Plus } from "lucide-react"; import { FormEventHandler, ReactNode, useId, useRef, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; @@ -171,10 +171,14 @@ function AddRepertoireDialogContent() { setIsLoading(true); - const { repertoireId } = yield* client.createRepertoire({ - name, - entries: [], - }); + const { repertoireId } = yield* pipe( + client.createRepertoire({ + name, + entries: [], + }), + Effect.flatMap(denormalizeRepertoire), + Effect.tap((repertoire) => repertoireCache.create(repertoire.repertoireId, repertoire)), + ); navigate(repertoireId); }));