From a199b104ade74277838235b23daef0218672f083 Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Fri, 10 Oct 2025 13:09:11 +0200 Subject: [PATCH] Cache reimplementation --- packages/common/src/Cache.ts | 149 +++++++++++++++++++ packages/frontend/src/cache.ts | 53 ++----- packages/frontend/src/hooks/useCache.ts | 72 +++++++++ packages/frontend/src/hooks/useLoading.ts | 67 +++++---- packages/frontend/src/routes/Piece.tsx | 37 ++--- packages/frontend/src/routes/Pieces.tsx | 68 +++++---- packages/frontend/src/routes/Repertoire.tsx | 62 ++++---- packages/frontend/src/routes/Repertoires.tsx | 3 +- 8 files changed, 356 insertions(+), 155 deletions(-) create mode 100644 packages/common/src/Cache.ts create mode 100644 packages/frontend/src/hooks/useCache.ts diff --git a/packages/common/src/Cache.ts b/packages/common/src/Cache.ts new file mode 100644 index 0000000..15cfb09 --- /dev/null +++ b/packages/common/src/Cache.ts @@ -0,0 +1,149 @@ +import { Deferred, Effect, Exit, flow, HashMap, Option } from "effect"; + +export type FetchFn = (key: K) => Effect.Effect; +export type ListenFn = (state: Map) => 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 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 { + 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; + readonly refresh: (key: K) => Effect.Effect; + readonly get: (key: K) => Effect.Effect; + readonly getCurrent: (key: K) => Option.Option>; + readonly subscribe: (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 listeners = new Set>(); + + const setPending = (key: K, deferred: Deferred.Deferred) => { + const pending = Pending(deferred); + map = HashMap.set(map, key, pending); + listeners.forEach((callback) => callback(map)); + }; + + const setFulfilled = (key: K, exit: Exit.Exit) => { + const fulfilled = Fulfilled(exit); + map = HashMap.set(map, key, fulfilled); + listeners.forEach((callback) => callback(map)); + }; + + const setFulfilledSucceed = (key: K, value: A) => { + setFulfilled(key, Exit.succeed(value)); + }; + + const unset = (key: K) => { + map = HashMap.remove(map, key); + listeners.forEach((callback) => callback(map)); + }; + + 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 refresh = (key: K): Effect.Effect => Effect.gen(function* () { + const deferred = yield* Deferred.make(); + setPending(key, deferred); + + const exit = yield* Effect.exit(fetchFn(key)); + 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); + } + + switch (state._tag) { + case "Pending": + return yield* Deferred.await(state.deferred); + case "Fulfilled": + return yield* state.exit; + } + }); + + const getCurrent = (key: K): Option.Option> => HashMap.get(map, key); + + const subscribe = (callback: ListenFn) => { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }; + + const cacheInterface = Object.freeze>({ + setPending, + setFulfilled, + setFulfilledSucceed, + unset, + update, + refresh, + get, + getCurrent, + subscribe, + }); + + return cacheInterface as Cache; +}; diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index f602e39..fc04efb 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -1,7 +1,7 @@ -import { PieceId, RepertoireId, UserId } from "common"; +import { client } from "@/client"; +import * as Cache from "common/Cache"; import the_api, { SystemInformation } from "common/the_api"; -import { Array, Cache, Duration, Effect, pipe } from "effect"; -import { client } from "./client"; +import { Array, Effect, flow, pipe } from "effect"; export const denormalizeSystemInformation = ({ createdBy, @@ -11,12 +11,12 @@ export const denormalizeSystemInformation = ({ Effect.all({ createdBy: pipe( createdBy, - Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))), + Effect.flatMap(userCache.get), Effect.optionFromOptional, ), modifiedBy: pipe( modifiedBy, - Effect.flatMap((userId) => Effect.uninterruptible(userCache.get(userId))), + Effect.flatMap(userCache.get), Effect.optionFromOptional, ), }, { concurrency: "unbounded" }), @@ -45,7 +45,7 @@ export const denormalizeRepertoire = ({ ...rest }: typeof the_api.record.getRepertoire.response.Type) => pipe( Effect.all({ - entries: Effect.all(entries.map((entry) => Effect.uninterruptible(pieceCache.get(entry))), { concurrency: "unbounded" }), + entries: Effect.all(entries.map(pieceCache.get), { concurrency: "unbounded" }), }, { concurrency: "unbounded" }), Effect.map((repertoire) => Object.freeze({ ...rest, @@ -54,42 +54,13 @@ export const denormalizeRepertoire = ({ Effect.flatMap(denormalizeSystemInformation), ); -const UserSemaphore = Effect.unsafeMakeSemaphore(1); -const PieceSemaphore = Effect.unsafeMakeSemaphore(4); -const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1); +export const userCache = Cache.make(client.getUser); +export const pieceCache = Cache.make(flow(client.getPiece, Effect.flatMap(denormalizePiece))); +export const repertoireCache = Cache.make(flow(client.getRepertoire, Effect.flatMap(denormalizeRepertoire))); -export const userCache = Effect.runSync(Cache.make({ - capacity: Infinity, - timeToLive: Duration.days(1), - lookup: (userId: UserId) => pipe( - client.getUser(userId), - UserSemaphore.withPermits(1), - ), -})); - -export const pieceCache = Effect.runSync(Cache.make({ - capacity: Infinity, - timeToLive: Duration.days(1), - lookup: (pieceId: PieceId) => pipe( - client.getPiece(pieceId), - Effect.flatMap(denormalizePiece), - PieceSemaphore.withPermits(1), - ), -})); - -export const repertoireCache = Effect.runSync(Cache.make({ - capacity: Infinity, - timeToLive: Duration.days(1), - lookup: (repertoireId: RepertoireId) => pipe( - client.getRepertoire(repertoireId), - Effect.flatMap(denormalizeRepertoire), - RepertoireSemaphore.withPermits(1), - ), -})); - -export type User = Effect.Effect.Success>; -export type Piece = Effect.Effect.Success>; -export type Repertoire = Effect.Effect.Success>; +export type User = Cache.Value; +export type Piece = Cache.Value; +export type Repertoire = Cache.Value; export type Attachment = Array.ReadonlyArray.Infer; export type DenormalizedSystemInformation = Effect.Effect.Success>>; diff --git a/packages/frontend/src/hooks/useCache.ts b/packages/frontend/src/hooks/useCache.ts new file mode 100644 index 0000000..26f4c85 --- /dev/null +++ b/packages/frontend/src/hooks/useCache.ts @@ -0,0 +1,72 @@ +import * as Cache from "common/Cache"; +import { Cause, Effect, Either, Exit, flow, Option } from "effect"; +import { useCallback, useEffect, useSyncExternalStore } from "react"; + +export namespace Loading { + export interface Pending { + readonly isLoading: true; + readonly data: null; + readonly error: null; + } + + export interface Success { + readonly isLoading: false; + readonly data: A; + readonly error: null; + } + + export interface Error { + readonly isLoading: false; + readonly data: null; + readonly error: E; + } +} + +export type Loading = + | Loading.Pending + | Loading.Success + | Loading.Error + ; + +const PENDING: Loading.Pending = Object.freeze({ + isLoading: true, + data: null, + error: null, +}); + +function resolveState(maybeState: Option.Option>): Loading { + const state = Option.getOrNull(maybeState); + + if (state === null) { + return PENDING; + } + + switch (state._tag) { + case "Pending": + return PENDING; + case "Fulfilled": + return Exit.match(state.exit, { + 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 }), + }), + ), + 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); +} diff --git a/packages/frontend/src/hooks/useLoading.ts b/packages/frontend/src/hooks/useLoading.ts index 1520aa4..6d1dc68 100644 --- a/packages/frontend/src/hooks/useLoading.ts +++ b/packages/frontend/src/hooks/useLoading.ts @@ -1,27 +1,26 @@ -import { mapProp, Update, Updater } from "@/hooks/useStore"; import { Console, Effect, Fiber, pipe } from "effect"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; export namespace Loading { export interface Pending { readonly isLoading: true; readonly data: null; readonly error: null; - readonly setData: null; + readonly refresh: Effect.Effect; } export interface Success { readonly isLoading: false; readonly data: A; readonly error: null; - readonly setData: Updater; + readonly refresh: Effect.Effect; } export interface Error { readonly isLoading: false; readonly data: null; readonly error: E; - readonly setData: null; + readonly refresh: Effect.Effect; } } @@ -31,7 +30,7 @@ export type Loading = | Loading.Error ; -const IS_LOADING = Object.freeze({ +const PENDING = Object.freeze({ isLoading: true, data: null, error: null, @@ -40,37 +39,47 @@ const IS_LOADING = Object.freeze({ export function useLoading(effect: Effect.Effect, deps: React.DependencyList) { - const [result, setResult] = useState>(IS_LOADING); const setResultEffect = useCallback((action: Loading) => Effect.sync(() => setResult(action)), []); - useEffect(() => { - setResult(IS_LOADING); + const refresh: Effect.Effect = useMemo(() => pipe( + effect, + Effect.tap((data) => setResultEffect(Object.freeze>({ + isLoading: false, + data, + error: null, + refresh, + }))), + Effect.catchAll((error) => setResultEffect(Object.freeze>({ + isLoading: false, + data: null, + error, + refresh, + }))), + Effect.catchAllDefect(Console.error), + ), [effect]); - const fiber = pipe( - effect, - Effect.tapBoth({ - onSuccess: (data) => setResultEffect(Object.freeze>({ - isLoading: false, - data, - error: null, - setData: (action) => setResult(mapProp("data", action) as Update>), - })), - onFailure: (error) => setResultEffect(Object.freeze>({ - isLoading: false, - data: null, - error, - setData: null, - })), - }), - Effect.catchAllDefect(Console.error), - Effect.runFork, - ); + const [result, setResult] = useState>(() => Object.freeze({ + isLoading: true, + data: null, + error: null, + refresh, + })); + + useEffect(() => { + setResult(Object.freeze({ + isLoading: true, + data: null, + error: null, + refresh, + })); + + const fiber = Effect.runFork(refresh); const interruptEffect = Fiber.interrupt(fiber); return () => { Effect.runFork(interruptEffect); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return result; diff --git a/packages/frontend/src/routes/Piece.tsx b/packages/frontend/src/routes/Piece.tsx index e780905..620b462 100644 --- a/packages/frontend/src/routes/Piece.tsx +++ b/packages/frontend/src/routes/Piece.tsx @@ -1,17 +1,17 @@ import { Attachment, denormalizeSystemInformation, pieceCache, type Piece } from "@/cache"; import { API_URL_PREFIX, client } from "@/client"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { useLoading } from "@/hooks/useLoading"; -import { mapProp, Update, Updater } from "@/hooks/useStore"; +import { useCache } from "@/hooks/useCache"; +import { mapProp } from "@/hooks/useStore"; import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets"; import { Label } from "@radix-ui/react-label"; import clsx from "clsx"; import { PieceId } from "common"; import * as Body from "common/Body"; import { getMediaTypeForFilename } from "common/MediaType"; -import { Cause, Clock, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect"; +import { Array, Cause, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect"; import { constant } from "effect/Function"; import { Download, Loader2, Trash, UploadCloud } from "lucide-react"; import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react"; @@ -21,12 +21,7 @@ export function Piece() { const id = PieceId.make(useParams().pieceId!); - const { isLoading, error, data, setData } = useLoading(Effect.uninterruptible(pieceCache.get(id)), [id]); - - const setAttachments = useCallback((action: Update) => { - setData!(mapProp("attachments", action)); - Effect.runFork(pieceCache.invalidate(id)); - }, [id, setData]); + const { isLoading, error, data } = useCache(pieceCache, id); if (isLoading) { return ( @@ -45,9 +40,9 @@ export function Piece() {

Utwór

Załączniki

- + - + )} ); @@ -102,10 +97,8 @@ function PieceForm(props: PieceForm.Props) { try { setIsDeleting(true); - await Effect.runPromise(pipe( - client.deletePiece(props.piece.pieceId), - Effect.andThen(pieceCache.invalidate(props.piece.pieceId)), - )); + await Effect.runPromise(client.deletePiece(props.piece.pieceId)); + pieceCache.unset(props.piece.pieceId); navigate(".."); } catch (error) { @@ -170,7 +163,6 @@ namespace Attachments { export interface Props { readonly pieceId: PieceId; readonly attachments: readonly Attachment[]; - readonly setAttachments: Updater; } } @@ -192,7 +184,6 @@ function Attachments(props: Attachments.Props) { ))} @@ -204,7 +195,6 @@ function Attachments(props: Attachments.Props) { namespace AttachmentRow { export interface Props { readonly attachment: Attachment; - readonly setAttachments: Updater; } } @@ -249,7 +239,7 @@ function AttachmentRow(props: AttachmentRow.Props) { const doDelete = () => Effect.gen(function* () { yield* client.deleteAttachment(props.attachment.attachmentId); - props.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId)); + pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter((a) => a.attachmentId !== props.attachment.attachmentId))); }).pipe(Effect.runPromise); return ( @@ -289,7 +279,6 @@ function AttachmentRow(props: AttachmentRow.Props) { namespace AttachmentForm { export interface Props { readonly pieceId: PieceId; - readonly setAttachments: Updater; } } @@ -343,11 +332,11 @@ function AttachmentForm(props: AttachmentForm.Props) { const attachment = yield* denormalizeSystemInformation(exit.value); - props.setAttachments((prev) => { - const next = [...prev, attachment]; + pieceCache.update(props.pieceId, mapProp("attachments", (attachments: readonly Attachment[]) => { + const next = [...attachments, attachment]; next.sort((a, b) => a.filename.localeCompare(b.filename)); return next; - }); + })); } })); }).pipe(Effect.runPromise); diff --git a/packages/frontend/src/routes/Pieces.tsx b/packages/frontend/src/routes/Pieces.tsx index d50903a..46a4268 100644 --- a/packages/frontend/src/routes/Pieces.tsx +++ b/packages/frontend/src/routes/Pieces.tsx @@ -1,10 +1,11 @@ -import { pieceCache } from "@/cache"; +import { denormalizePiece, pieceCache } from "@/cache"; import { client } from "@/client"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; 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"; @@ -12,8 +13,8 @@ import clsx from "clsx"; import { PieceId } from "common"; import * as Body from "common/Body"; import { getMediaTypeForFilename } from "common/MediaType"; -import { Array, Cause, Effect, Fiber, Iterable, Match, Option, Order, pipe, Scope, SortedMap } from "effect"; -import { Import, Loader2, Plus, UploadCloud } from "lucide-react"; +import { Array, Cause, Effect, Fiber, Iterable, Match, Option, Order, pipe, Predicate, Scope, SortedMap } from "effect"; +import { Import, Loader2, Plus } from "lucide-react"; import { DragEventHandler, FormEventHandler, useId, useRef, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; @@ -119,7 +120,7 @@ namespace PieceRow { function PieceRow(props: PieceRow.Props) { - const { isLoading, error, data: piece } = useLoading(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]); + const { isLoading, error, data: piece } = useCache(pieceCache, props.pieceId); if (isLoading) { return ( @@ -188,12 +189,16 @@ function AddPieceDialogContent() { setIsLoading(true); - const { pieceId } = yield* client.createPiece({ - 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(), - }); + const { pieceId } = yield* pipe( + client.createPiece({ + 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) => Effect.sync(() => pieceCache.setFulfilledSucceed(piece.pieceId, piece))), + ); navigate(pieceId); })); @@ -262,7 +267,7 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) { const [isLoading, setIsLoading] = useState(false); - const [attachments, setAttachments] = useState>(() => SortedMap.empty(Order.string)); + const [files, setFiles] = useState>(() => SortedMap.empty(Order.string)); const onDragOver: DragEventHandler = (e) => { e.preventDefault(); @@ -272,7 +277,7 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) { const onDrop: DragEventHandler = (e) => Effect.gen(function* () { e.preventDefault(); - setAttachments((value) => { + setFiles((value) => { for (const file of e.dataTransfer.files) { const mediaType = getMediaTypeForFilename(file.name); if (mediaType === undefined) { @@ -314,18 +319,20 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) { setIsLoading(true); yield* pipe( - attachments, - Iterable.map(([name, attachments]) => Effect.gen(function* () { + files, + Iterable.map(([name, files]) => Effect.gen(function* () { - const { pieceId } = yield* client.createPiece({ - name, - composer: Option.none(), - arranger: Option.none(), - lyricist: Option.none(), - }); + const piece = yield* pipe( + client.createPiece({ + name, + composer: Option.none(), + arranger: Option.none(), + lyricist: Option.none(), + }), + ); - yield* pipe( - attachments, + const attachments = yield* pipe( + files, Array.map((file) => Effect.gen(function* () { const mediaType = getMediaTypeForFilename(file.name); if (mediaType === undefined) { @@ -335,14 +342,23 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) { // NOTE Apparently, file.bytes is not a thing in this context const data = new Uint8Array(yield* Body.arrayBuffer(file)); - yield* client.createAttachment({ - pieceId, + const attachment = yield* client.createAttachment({ + pieceId: piece.pieceId, data, filename: file.name, mediaType, }); + + return attachment; })), Effect.allWith({ concurrency: "unbounded" }), + Effect.map(Array.filter(Predicate.isNotUndefined)), + ); + + yield* pipe( + { ...piece, attachments }, + denormalizePiece, + Effect.tap((piece) => Effect.sync(() => pieceCache.setFulfilledSucceed(piece.pieceId, piece))), ); })), Effect.allWith({ concurrency: "unbounded" }), @@ -359,7 +375,7 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) { Importuj utwory
- {SortedMap.size(attachments) > 0 && ( + {SortedMap.size(files) > 0 && (
@@ -370,7 +386,7 @@ function ImportPiecesDialogContent(props: ImportPiecesDialogContent.Props) { {pipe( - attachments, + files, SortedMap.entries, Iterable.map(([name, files]) => ( diff --git a/packages/frontend/src/routes/Repertoire.tsx b/packages/frontend/src/routes/Repertoire.tsx index c110968..36b545c 100644 --- a/packages/frontend/src/routes/Repertoire.tsx +++ b/packages/frontend/src/routes/Repertoire.tsx @@ -1,29 +1,25 @@ -import { Piece, pieceCache, type Repertoire, repertoireCache } from "@/cache"; +import { denormalizeRepertoire, Piece, pieceCache, type Repertoire, repertoireCache } from "@/cache"; import { client } from "@/client"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; 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 { mapProp, Update, Updater } from "@/hooks/useStore"; -import { authors, DEBOUNCE, SAVE_DELAY, saveDelay } from "@/snippets"; +import { Updater } from "@/hooks/useStore"; +import { authors, DEBOUNCE, SAVE_DELAY } from "@/snippets"; import { PieceId, RepertoireId } from "common"; import { Array, Cause, Effect, Fiber, Match, Option, pipe, Scope } from "effect"; import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react"; -import { FormEventHandler, useCallback, useId, useRef, useState } from "react"; +import { FormEventHandler, useId, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; export function Repertoire() { const id = RepertoireId.make(useParams().repertoireId!); - const { isLoading, error, data, setData } = useLoading(Effect.uninterruptible(repertoireCache.get(id)), [id]); - - const setEntries = useCallback((action: Update) => { - setData!(mapProp("entries", action)); - Effect.runFork(repertoireCache.invalidate(id)); - }, [id, setData]); + const { isLoading, error, data } = useCache(repertoireCache, id); if (isLoading) { return ( @@ -42,7 +38,7 @@ export function Repertoire() {

Repertuar

- + )} ); @@ -94,8 +90,8 @@ function RepertoireForm(props: RepertoireForm.Props) { setIsDeleting(true); yield* client.deleteRepertoire(props.repertoire.repertoireId); + repertoireCache.unset(props.repertoire.repertoireId); - yield* repertoireCache.invalidate(props.repertoire.repertoireId); navigate(".."); })).pipe(Effect.runPromise); @@ -129,7 +125,6 @@ function RepertoireForm(props: RepertoireForm.Props) { namespace Entries { export interface Props { readonly repertoire: Repertoire; - readonly setEntries: Updater; } } @@ -155,7 +150,6 @@ function Entries(props: Entries.Props) { repertoire={props.repertoire} piece={entry} no={i + 1} - setEntries={props.setEntries} /> ))}
@@ -166,7 +160,7 @@ function Entries(props: Entries.Props) { Dodaj utwór - + ); @@ -177,7 +171,6 @@ namespace EntryRow { readonly repertoire: Repertoire; readonly piece: Piece; readonly no: number; - readonly setEntries: Updater; } } @@ -185,7 +178,6 @@ function EntryRow({ repertoire, piece, no, - setEntries, }: EntryRow.Props) { const moveUpAction = (entries: readonly Piece[]) => pipe( @@ -211,13 +203,15 @@ function EntryRow({ const mapToId = Array.map(({ pieceId }) => pieceId); - yield* client.updateRepertoire({ - repertoireId: repertoire.repertoireId, - name: repertoire.name, - entries: pipe(repertoire.entries, action, mapToId), - }); - - setEntries(action); + yield* pipe( + client.updateRepertoire({ + repertoireId: repertoire.repertoireId, + name: repertoire.name, + entries: pipe(repertoire.entries, action, mapToId), + }), + Effect.flatMap(denormalizeRepertoire), + Effect.tap((repertoire) => Effect.sync(() => repertoireCache.setFulfilledSucceed(repertoire.repertoireId, repertoire))), + ); }); const moveUp = () => Effect.runPromise(update(moveUpAction)); @@ -257,7 +251,6 @@ const ADD_ENTRY_DIALOG_LIMIT = 10; namespace AddEntryDialogContent { export interface Props { readonly repertoire: Repertoire; - readonly setEntries: Updater; readonly setDialogOpen: Updater; } } @@ -332,7 +325,6 @@ function AddEntryDialogContent(props: AddEntryDialogContent.Props) { key={pieceId} repertoire={props.repertoire} pieceId={pieceId} - setEntries={props.setEntries} setDialogOpen={props.setDialogOpen} /> )) @@ -348,27 +340,29 @@ namespace EntryDialogPieceRow { export interface Props { readonly repertoire: Repertoire; readonly pieceId: PieceId; - readonly setEntries: Updater; readonly setDialogOpen: Updater; } } function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) { - const { isLoading, error, data: piece } = useLoading(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]); + const { isLoading, error, data: piece } = useCache(pieceCache, props.pieceId); const onClick = () => Effect.gen(function* () { const action = Array.append(piece!); const mapToId = Array.map(({ pieceId }) => pieceId); - yield* client.updateRepertoire({ - repertoireId: props.repertoire.repertoireId, - name: props.repertoire.name, - entries: pipe(props.repertoire.entries, action, mapToId), - }); + yield* pipe( + client.updateRepertoire({ + repertoireId: props.repertoire.repertoireId, + name: props.repertoire.name, + entries: pipe(props.repertoire.entries, action, mapToId), + }), + Effect.flatMap(denormalizeRepertoire), + Effect.tap((repertoire) => Effect.sync(() => repertoireCache.setFulfilledSucceed(repertoire.repertoireId, repertoire))), + ); - props.setEntries(action); props.setDialogOpen(false); }).pipe(Effect.runPromise); diff --git a/packages/frontend/src/routes/Repertoires.tsx b/packages/frontend/src/routes/Repertoires.tsx index 6d3685c..1add3df 100644 --- a/packages/frontend/src/routes/Repertoires.tsx +++ b/packages/frontend/src/routes/Repertoires.tsx @@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogT import { Input } from "@/components/ui/input"; 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 { created, DEBOUNCE, modified } from "@/snippets"; import { RepertoireId } from "common"; @@ -93,7 +94,7 @@ namespace RepertoireRow { function RepertoireRow(props: RepertoireRow.Props) { - const { isLoading, error, data: repertoire } = useLoading(Effect.uninterruptible(repertoireCache.get(props.repertoireId)), [props.repertoireId]); + const { isLoading, error, data: repertoire } = useCache(repertoireCache, props.repertoireId); if (isLoading) { return (