Reimplement cache more lol

This commit is contained in:
2025-10-10 14:54:42 +02:00
parent a199b104ad
commit 6ea21aa0f1
8 changed files with 224 additions and 130 deletions

View File

@@ -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<Loading.Pending>({
error: null,
});
function resolveState<A, E>(maybeState: Option.Option<Cache.State<A, E>>): Loading<A, E> {
const state = Option.getOrNull(maybeState);
const pending = constant(PENDING);
if (state === null) {
return PENDING;
}
const success = <A>(data: A): Loading.Success<A> => Object.freeze<Loading.Success<A>>({
isLoading: false,
data,
error: null,
});
switch (state._tag) {
case "Pending":
return PENDING;
case "Fulfilled":
return Exit.match(state.exit, {
const error = <E>(error: E): Loading.Error<E> => Object.freeze<Loading.Error<E>>({
isLoading: false,
data: null,
error,
});
export function useCache<K, A, E>(cache: Cache.Cache<K, A, E>, key: NoInfer<K>): Loading<A, E> {
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<Loading.Error<E>>({ isLoading: false, data: null, error }),
onRight: (cause) => Object.freeze<Loading.Error<E>>({ isLoading: false, data: null, error: cause as any }),
onLeft: error,
onRight: (cause) => error(cause as any),
}),
),
onSuccess: (data) => Object.freeze<Loading.Success<A>>({ isLoading: false, data, error: null }),
});
}
}
export function useCache<K, A, E>(cache: Cache.Cache<K, A, E>, key: NoInfer<K>, 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,
}),
}),
});
}

View File

@@ -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> = T | ((prev: T) => T);
export type Updater<T> = (action: Update<T>) => void;
export const mapProp = <const K extends string, T>(prop: K, action: Update<T>) => <O extends { readonly [_ in K]: T }>(object: O): O => {
return Object.freeze({ ...object, [prop]: typeof action === "function" ? (action as (prev: T) => T)(object[prop]) : action });
};

View File

@@ -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<Attachment>((a) => a.attachmentId !== props.attachment.attachmentId)));
yield* pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter<Attachment>((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;

View File

@@ -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() {
<Import />Importuj utwory
</Button>
</DialogTrigger>
<ImportPiecesDialogContent setDialogOpen={setImportDialogOpen} />
<ImportPiecesDialogContent setDialogOpen={setImportDialogOpen} refresh={refresh} />
</Dialog>
<Input
className="w-[32ch]"
@@ -197,7 +196,7 @@ function AddPieceDialogContent() {
arranger: arranger.length > 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<boolean>;
readonly refresh: Effect.Effect<void>;
}
}
@@ -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);

View File

@@ -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);

View File

@@ -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);
}));