디렉터리 관련 TanStack Query 코드 리팩토링

This commit is contained in:
static
2025-07-17 11:43:22 +09:00
parent 164c5f660b
commit e10b600293
9 changed files with 139 additions and 208 deletions

View File

@@ -1,11 +1,6 @@
import { get, writable, type Writable } from "svelte/store";
import { callGetApi } from "$lib/hooks";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
@@ -14,35 +9,15 @@ import {
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import type {
CategoryInfoResponse,
CategoryFileListResponse,
DirectoryInfoResponse,
FileInfoResponse,
} from "$lib/server/schemas";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
dataKey?: CryptoKey;
@@ -75,92 +50,9 @@ export type CategoryInfo =
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({ id, name: directory.name, subDirectoryIds, fileIds });
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
const res = await callGetApi(`/api/directory/${id}`);
if (res.status === 404) {
info.set(null);
await deleteDirectoryInfo(id as number);
return;
} else if (!res.ok) {
throw new Error("Failed to fetch directory information");
}
const {
metadata,
subDirectories: subDirectoryIds,
files: fileIds,
}: DirectoryInfoResponse = await res.json();
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchDirectoryInfoFromIndexedDB(id, info);
await fetchDirectoryInfoFromServer(id, info, masterKey);
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(id);
if (!info) {
info = writable(null);
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey); // Intended
return info;
};
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;

View File

@@ -26,15 +26,33 @@ import {
encryptString,
decryptString,
} from "$lib/modules/crypto";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import type {
DirectoryInfoResponse,
DirectoryDeleteResponse,
DirectoryRenameRequest,
DirectoryCreateRequest,
DirectoryCreateResponse,
DirectoryInfoResponse,
DirectoryRenameRequest,
} from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
const initializedDirectoryIds = new Set<DirectoryId>();
let temporaryIdCounter = -1;
@@ -99,15 +117,10 @@ export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
export type DirectoryInfoStore = ReturnType<typeof getDirectoryInfo>;
export const useDirectoryCreate = (parentId: DirectoryId) => {
export const useDirectoryCreation = (parentId: DirectoryId, masterKey: MasterKey) => {
const queryClient = useQueryClient();
return createMutation<
{ id: number; dataKey: CryptoKey; dataKeyVersion: Date },
Error,
{ name: string; masterKey: MasterKey },
{ prevParentInfo: DirectoryInfo | undefined; tempId: number }
>({
mutationFn: async ({ name, masterKey }) => {
return createMutation<void, Error, { name: string }, { tempId: number }>({
mutationFn: async ({ name }) => {
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
@@ -119,30 +132,9 @@ export const useDirectoryCreate = (parentId: DirectoryId) => {
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to create directory");
const { directory: id }: DirectoryCreateResponse = await res.json();
return { id, dataKey, dataKeyVersion };
},
onMutate: async ({ name }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
const prevParentInfo = queryClient.getQueryData<DirectoryInfo>(["directory", parentId]);
const tempId = temporaryIdCounter--;
if (prevParentInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], {
...prevParentInfo,
subDirectoryIds: [...prevParentInfo.subDirectoryIds, tempId],
});
queryClient.setQueryData<DirectoryInfo>(["directory", tempId], {
id: tempId,
name,
subDirectoryIds: [],
fileIds: [],
});
}
return { prevParentInfo, tempId };
},
onSuccess: async ({ id, dataKey, dataKeyVersion }, { name }) => {
queryClient.setQueryData<DirectoryInfo>(["directory", id], {
id,
name,
@@ -153,13 +145,38 @@ export const useDirectoryCreate = (parentId: DirectoryId) => {
});
await storeDirectoryInfo({ id, parentId, name });
},
onError: (error, { name }, context) => {
if (context?.prevParentInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], context.prevParentInfo);
}
console.error(`Failed to create directory "${name}" in parent ${parentId}:`, error);
onMutate: async ({ name }) => {
const tempId = temporaryIdCounter--;
queryClient.setQueryData<DirectoryInfo>(["directory", tempId], {
id: tempId,
name,
subDirectoryIds: [],
fileIds: [],
});
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: [...prevParentInfo.subDirectoryIds, tempId],
};
});
return { tempId };
},
onSettled: (id) => {
onError: (_error, _variables, context) => {
if (context) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: prevParentInfo.subDirectoryIds.filter((id) => id !== context.tempId),
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
@@ -176,15 +193,18 @@ export const useDirectoryRename = () => {
dataKeyVersion: Date;
newName: string;
},
{ prevInfo: (DirectoryInfo & { id: number }) | undefined }
{ oldName: string | undefined }
>({
mutationFn: async ({ id, dataKey, dataKeyVersion, newName }) => {
const newNameEncrypted = await encryptString(newName, dataKey);
await callPostApi<DirectoryRenameRequest>(`/api/directory/${id}/rename`, {
const res = await callPostApi<DirectoryRenameRequest>(`/api/directory/${id}/rename`, {
dekVersion: dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to rename directory");
await updateDirectoryInfo(id, { name: newName });
},
onMutate: async ({ id, newName }) => {
await queryClient.cancelQueries({ queryKey: ["directory", id] });
@@ -195,61 +215,62 @@ export const useDirectoryRename = () => {
...prevInfo,
name: newName,
});
await updateDirectoryInfo(id, { name: newName });
}
return { prevInfo };
return { oldName: prevInfo?.name };
},
onSuccess: async (data, { id, newName }) => {
await updateDirectoryInfo(id, { name: newName });
},
onError: (error, { id }, context) => {
if (context?.prevInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", id], context.prevInfo);
onError: (_error, { id }, context) => {
if (context?.oldName) {
queryClient.setQueryData<DirectoryInfo & { id: number }>(["directory", id], (prevInfo) => {
if (!prevInfo) return undefined;
return { ...prevInfo, name: context.oldName! };
});
}
console.error("Failed to rename directory:", error);
},
onSettled: (data, error, { id }) => {
onSettled: (_data, _error, { id }) => {
queryClient.invalidateQueries({ queryKey: ["directory", id] });
},
});
};
export const useDirectoryDelete = (parentId: DirectoryId) => {
export const useDirectoryDeletion = (parentId: DirectoryId) => {
const queryClient = useQueryClient();
return createMutation<
void,
Error,
{ id: number },
{ prevInfo: (DirectoryInfo & { id: number }) | undefined }
>({
return createMutation<{ deletedFiles: number[] }, Error, { id: number }, {}>({
mutationFn: async ({ id }) => {
await callPostApi(`/api/directory/${id}/delete`);
const res = await callPostApi(`/api/directory/${id}/delete`);
if (!res.ok) throw new Error("Failed to delete directory");
const { deletedDirectories, deletedFiles }: DirectoryDeleteResponse = await res.json();
await Promise.all([
...deletedDirectories.map(deleteDirectoryInfo),
...deletedFiles.map(deleteFileInfo),
]);
return { deletedFiles };
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
const prevParentInfo = queryClient.getQueryData<DirectoryInfo>(["directory", parentId]);
if (prevParentInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: prevParentInfo.subDirectoryIds.filter((subId) => subId !== id),
};
});
return {};
},
onError: (_error, { id }, context) => {
if (context) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: [...prevParentInfo.subDirectoryIds, id],
};
});
}
const prevInfo = queryClient.getQueryData<DirectoryInfo & { id: number }>(["directory", id]);
return { prevInfo };
},
onSuccess: async (data, { id }) => {
await deleteDirectoryInfo(id);
},
onError: (error, { id }, context) => {
if (context?.prevInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], context?.prevInfo);
}
console.error("Failed to delete directory:", error);
},
onSettled: (data, error, { id }) => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});

View File

@@ -181,7 +181,10 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
};
const unregisterDirectoryRecursively = async (
directoryId: number,
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
): Promise<{
subDirectories: { id: number }[];
files: { id: number; path: string; thumbnailPath: string | null }[];
}> => {
const files = await unregisterFiles(directoryId);
const subDirectories = await trx
.selectFrom("directory")
@@ -189,7 +192,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
.where("parent_id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
const subDirectoryFilePaths = await Promise.all(
const subDirectoryEntries = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
@@ -201,7 +204,12 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
if (deleteRes.numDeletedRows === 0n) {
throw new IntegrityError("Directory not found");
}
return files.concat(...subDirectoryFilePaths);
return {
subDirectories: subDirectoryEntries
.flatMap(({ subDirectories }) => subDirectories)
.concat(subDirectories),
files: subDirectoryEntries.flatMap(({ files }) => files).concat(files),
};
};
return await unregisterDirectoryRecursively(directoryId);
});

View File

@@ -19,6 +19,7 @@ export const directoryInfoResponse = z.object({
export type DirectoryInfoResponse = z.output<typeof directoryInfoResponse>;
export const directoryDeleteResponse = z.object({
deletedDirectories: z.number().int().positive().array(),
deletedFiles: z.number().int().positive().array(),
});
export type DirectoryDeleteResponse = z.output<typeof directoryDeleteResponse>;

View File

@@ -42,8 +42,9 @@ const safeUnlink = async (path: string | null) => {
export const deleteDirectory = async (userId: number, directoryId: number) => {
try {
const files = await unregisterDirectory(userId, directoryId);
const { subDirectories, files } = await unregisterDirectory(userId, directoryId);
return {
directories: [...subDirectories.map(({ id }) => id), directoryId],
files: files.map(({ id, path, thumbnailPath }) => {
safeUnlink(path); // Intended
safeUnlink(thumbnailPath); // Intended

View File

@@ -1,15 +1,14 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { type DirectoryInfo } from "$lib/modules/filesystem";
import { deleteFileCache, deleteFileThumbnailCache } from "$lib/modules/file";
import {
getDirectoryInfo,
useDirectoryCreate,
useDirectoryCreation,
useDirectoryRename,
useDirectoryDelete,
useDirectoryDeletion,
} from "$lib/modules/filesystem2";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
@@ -35,9 +34,9 @@
let context = createContext();
let info = $derived(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!));
let requestDirectoryCreation = $derived(useDirectoryCreate(data.id));
let requestDirectoryCreation = $derived(useDirectoryCreation(data.id, $masterKeyStore?.get(1)!));
let requestDirectoryRename = useDirectoryRename();
let requestDirectoryDeletion = $derived(useDirectoryDelete(data.id));
let requestDirectoryDeletion = $derived(useDirectoryDeletion(data.id));
let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state();
@@ -135,10 +134,7 @@
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
$requestDirectoryCreation.mutate({
name,
masterKey: $masterKeyStore?.get(1)!,
});
$requestDirectoryCreation.mutate({ name });
return true; // TODO
}}
/>
@@ -190,9 +186,16 @@
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (context.selectedEntry!.type === "directory") {
$requestDirectoryDeletion.mutate({
const res = await $requestDirectoryDeletion.mutateAsync({
id: context.selectedEntry!.id,
});
if (!res) return false;
await Promise.all(
res.deletedFiles.flatMap((fileId) => [
deleteFileCache(fileId),
deleteFileThumbnailCache(fileId),
]),
);
return true; // TODO
} else {
if (await requestEntryDeletion(context.selectedEntry!)) {

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Readable, type Writable } from "svelte/store";
import { getFileInfo, type DirectoryInfo, type FileInfo } from "$lib/modules/filesystem";
import { getDirectoryInfo, type DirectoryInfoStore } from "$lib/modules/filesystem2";
import { get, type Writable } from "svelte/store";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import {
getDirectoryInfo,
type DirectoryInfo,
type DirectoryInfoStore,
} from "$lib/modules/filesystem2";
import { SortBy, sortEntries } from "$lib/modules/util";
import {
fileUploadStatusStore,

View File

@@ -1,9 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import type { DirectoryInfoStore } from "$lib/modules/filesystem2";
import type { DirectoryInfo, DirectoryInfoStore } from "$lib/modules/filesystem2";
import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";

View File

@@ -16,8 +16,11 @@ export const POST: RequestHandler = async ({ locals, params }) => {
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { files } = await deleteDirectory(userId, id);
const { directories, files } = await deleteDirectory(userId, id);
return json(
directoryDeleteResponse.parse({ deletedFiles: files } satisfies DirectoryDeleteResponse),
directoryDeleteResponse.parse({
deletedDirectories: directories,
deletedFiles: files,
} satisfies DirectoryDeleteResponse),
);
};