디렉터리 관련 API 요청을 TanStack Query로 마이그레이션 (WiP)

This commit is contained in:
static
2025-07-17 10:07:50 +09:00
parent 236484e4a0
commit 164c5f660b
9 changed files with 335 additions and 45 deletions

View File

@@ -62,6 +62,10 @@ export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.put(directoryInfo); await filesystem.directory.put(directoryInfo);
}; };
export const updateDirectoryInfo = async (id: number, changes: { name?: string }) => {
await filesystem.directory.update(id, changes);
};
export const deleteDirectoryInfo = async (id: number) => { export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id); await filesystem.directory.delete(id);
}; };

View File

@@ -0,0 +1,256 @@
import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query";
import { browser } from "$app/environment";
import { callGetApi, callPostApi } from "$lib/hooks";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
updateDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import {
generateDataKey,
wrapDataKey,
unwrapDataKey,
encryptString,
decryptString,
} from "$lib/modules/crypto";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import type {
DirectoryCreateRequest,
DirectoryCreateResponse,
DirectoryInfoResponse,
DirectoryRenameRequest,
} from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
const initializedDirectoryIds = new Set<DirectoryId>();
let temporaryIdCounter = -1;
const getInitialDirectoryInfo = async (id: DirectoryId) => {
if (!browser || initializedDirectoryIds.has(id)) {
return undefined;
} else {
initializedDirectoryIds.add(id);
}
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") {
return { id, subDirectoryIds, fileIds };
} else if (directory) {
return { id, name: directory.name, subDirectoryIds, fileIds };
}
return undefined;
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
const queryClient = useQueryClient();
getInitialDirectoryInfo(id).then((info) => {
if (info && !queryClient.getQueryData(["directory", id])) {
queryClient.setQueryData<DirectoryInfo>(["directory", id], info);
}
}); // Intended
return createQuery<DirectoryInfo>({
queryKey: ["directory", id],
queryFn: async () => {
const res = await callGetApi(`/api/directory/${id}`); // TODO: 404
const {
metadata,
subDirectories: subDirectoryIds,
files: fileIds,
}: DirectoryInfoResponse = await res.json();
if (id === "root") {
return { id, subDirectoryIds, fileIds };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
return {
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
};
}
},
});
};
export type DirectoryInfoStore = ReturnType<typeof getDirectoryInfo>;
export const useDirectoryCreate = (parentId: DirectoryId) => {
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 }) => {
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
const res = await callPostApi<DirectoryCreateRequest>(`/api/directory/create`, {
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
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,
dataKey,
dataKeyVersion,
subDirectoryIds: [],
fileIds: [],
});
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);
},
onSettled: (id) => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};
export const useDirectoryRename = () => {
const queryClient = useQueryClient();
return createMutation<
void,
Error,
{
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
newName: string;
},
{ prevInfo: (DirectoryInfo & { id: number }) | undefined }
>({
mutationFn: async ({ id, dataKey, dataKeyVersion, newName }) => {
const newNameEncrypted = await encryptString(newName, dataKey);
await callPostApi<DirectoryRenameRequest>(`/api/directory/${id}/rename`, {
dekVersion: dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
},
onMutate: async ({ id, newName }) => {
await queryClient.cancelQueries({ queryKey: ["directory", id] });
const prevInfo = queryClient.getQueryData<DirectoryInfo & { id: number }>(["directory", id]);
if (prevInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", id], {
...prevInfo,
name: newName,
});
await updateDirectoryInfo(id, { name: newName });
}
return { prevInfo };
},
onSuccess: async (data, { id, newName }) => {
await updateDirectoryInfo(id, { name: newName });
},
onError: (error, { id }, context) => {
if (context?.prevInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", id], context.prevInfo);
}
console.error("Failed to rename directory:", error);
},
onSettled: (data, error, { id }) => {
queryClient.invalidateQueries({ queryKey: ["directory", id] });
},
});
};
export const useDirectoryDelete = (parentId: DirectoryId) => {
const queryClient = useQueryClient();
return createMutation<
void,
Error,
{ id: number },
{ prevInfo: (DirectoryInfo & { id: number }) | undefined }
>({
mutationFn: async ({ id }) => {
await callPostApi(`/api/directory/${id}/delete`);
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
const prevParentInfo = queryClient.getQueryData<DirectoryInfo>(["directory", parentId]);
if (prevParentInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], {
...prevParentInfo,
subDirectoryIds: prevParentInfo.subDirectoryIds.filter((subId) => subId !== 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 }) => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};

View File

@@ -39,7 +39,7 @@ interface File {
export type NewFile = Omit<File, "id">; export type NewFile = Omit<File, "id">;
export const registerDirectory = async (params: NewDirectory) => { export const registerDirectory = async (params: NewDirectory) => {
await db.transaction().execute(async (trx) => { return await db.transaction().execute(async (trx) => {
const mek = await trx const mek = await trx
.selectFrom("master_encryption_key") .selectFrom("master_encryption_key")
.select("version") .select("version")
@@ -73,6 +73,7 @@ export const registerDirectory = async (params: NewDirectory) => {
new_name: params.encName, new_name: params.encName,
}) })
.execute(); .execute();
return { id: directoryId };
}); });
}; };

View File

@@ -39,3 +39,8 @@ export const directoryCreateRequest = z.object({
nameIv: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(),
}); });
export type DirectoryCreateRequest = z.input<typeof directoryCreateRequest>; export type DirectoryCreateRequest = z.input<typeof directoryCreateRequest>;
export const directoryCreateResponse = z.object({
directory: z.number().int().positive(),
});
export type DirectoryCreateResponse = z.output<typeof directoryCreateResponse>;

View File

@@ -86,7 +86,8 @@ export const createDirectory = async (params: NewDirectory) => {
} }
try { try {
await registerDirectory(params); const { id } = await registerDirectory(params);
return { id };
} catch (e) { } catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") { if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version"); error(400, "Invalid MEK version");

View File

@@ -4,7 +4,13 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { FloatingButton } from "$lib/components/atoms"; import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem"; import { type DirectoryInfo } from "$lib/modules/filesystem";
import {
getDirectoryInfo,
useDirectoryCreate,
useDirectoryRename,
useDirectoryDelete,
} from "$lib/modules/filesystem2";
import { masterKeyStore, hmacSecretStore } from "$lib/stores"; import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntries from "./DirectoryEntries";
@@ -18,7 +24,6 @@
import { import {
createContext, createContext,
requestHmacSecretDownload, requestHmacSecretDownload,
requestDirectoryCreation,
requestFileUpload, requestFileUpload,
requestEntryRename, requestEntryRename,
requestEntryDeletion, requestEntryDeletion,
@@ -29,7 +34,11 @@
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let info: Writable<DirectoryInfo | null> | undefined = $state(); let info = $derived(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!));
let requestDirectoryCreation = $derived(useDirectoryCreate(data.id));
let requestDirectoryRename = useDirectoryRename();
let requestDirectoryDeletion = $derived(useDirectoryDelete(data.id));
let fileInput: HTMLInputElement | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state(); let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state(); let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -57,7 +66,7 @@
.then((res) => { .then((res) => {
if (!res) return; if (!res) return;
// TODO: FIXME // TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}) })
.catch((e: Error) => { .catch((e: Error) => {
// TODO: FIXME // TODO: FIXME
@@ -73,10 +82,6 @@
throw new Error("Failed to download hmac secrets"); throw new Error("Failed to download hmac secrets");
} }
}); });
$effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script> </script>
<svelte:head> <svelte:head>
@@ -87,9 +92,9 @@
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
{#if data.id !== "root"} {#if data.id !== "root"}
<TopBar title={$info?.name} class="flex-shrink-0" /> <TopBar title={$info.data?.name} class="flex-shrink-0" />
{/if} {/if}
{#if $info} {#if $info.status === "success"}
<div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}> <div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}>
<div class="flex gap-x-2"> <div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} /> <UploadStatusCard onclick={() => goto("/file/uploads")} />
@@ -97,7 +102,7 @@
</div> </div>
{#key $info} {#key $info}
<DirectoryEntries <DirectoryEntries
info={$info} info={$info.data}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)} onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => { onEntryMenuClick={(entry) => {
context.selectedEntry = entry; context.selectedEntry = entry;
@@ -130,11 +135,11 @@
<DirectoryCreateModal <DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen} bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => { onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { $requestDirectoryCreation.mutate({
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME name,
return true; masterKey: $masterKeyStore?.get(1)!,
} });
return false; return true; // TODO
}} }}
/> />
<DuplicateFileModal <DuplicateFileModal
@@ -164,20 +169,37 @@
<EntryRenameModal <EntryRenameModal
bind:isOpen={isEntryRenameModalOpen} bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => { onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) { if (context.selectedEntry!.type === "directory") {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME $requestDirectoryRename.mutate({
return true; id: context.selectedEntry!.id,
dataKey: context.selectedEntry!.dataKey,
dataKeyVersion: context.selectedEntry!.dataKeyVersion,
newName,
});
return true; // TODO
} else {
if (await requestEntryRename(context.selectedEntry!, newName)) {
// info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
} }
return false;
}} }}
/> />
<EntryDeleteModal <EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen} bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => { onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) { if (context.selectedEntry!.type === "directory") {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME $requestDirectoryDeletion.mutate({
return true; id: context.selectedEntry!.id,
});
return true; // TODO
} else {
if (await requestEntryDeletion(context.selectedEntry!)) {
// info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
} }
return false;
}} }}
/> />

View File

@@ -1,12 +1,8 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store"; import { get, type Readable, type Writable } from "svelte/store";
import { import { getFileInfo, type DirectoryInfo, type FileInfo } from "$lib/modules/filesystem";
getDirectoryInfo, import { getDirectoryInfo, type DirectoryInfoStore } from "$lib/modules/filesystem2";
getFileInfo,
type DirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util"; import { SortBy, sortEntries } from "$lib/modules/util";
import { import {
fileUploadStatusStore, fileUploadStatusStore,
@@ -30,7 +26,7 @@
interface DirectoryEntry { interface DirectoryEntry {
name?: string; name?: string;
info: Writable<DirectoryInfo | null>; info: DirectoryInfoStore;
} }
type FileEntry = type FileEntry =
@@ -53,7 +49,7 @@
subDirectories = info.subDirectoryIds.map((id) => { subDirectories = info.subDirectoryIds.map((id) => {
const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!); const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!);
return { name: get(info)?.name, info }; return { name: get(info).data?.name, info };
}); });
files = info.fileIds files = info.fileIds
.map((id): FileEntry => { .map((id): FileEntry => {
@@ -87,8 +83,8 @@
const unsubscribes = subDirectories const unsubscribes = subDirectories
.map((subDirectory) => .map((subDirectory) =>
subDirectory.info.subscribe((value) => { subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return; if (subDirectory.name === value.data?.name) return;
subDirectory.name = value?.name; subDirectory.name = value.data?.name;
sort(); sort();
}), }),
) )

View File

@@ -3,6 +3,7 @@
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { DirectoryInfo } from "$lib/modules/filesystem"; import type { DirectoryInfo } from "$lib/modules/filesystem";
import type { DirectoryInfoStore } from "$lib/modules/filesystem2";
import type { SelectedEntry } from "../service.svelte"; import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert"; import IconMoreVert from "~icons/material-symbols/more-vert";
@@ -10,7 +11,7 @@
type SubDirectoryInfo = DirectoryInfo & { id: number }; type SubDirectoryInfo = DirectoryInfo & { id: number };
interface Props { interface Props {
info: Writable<DirectoryInfo | null>; info: DirectoryInfoStore;
onclick: (selectedEntry: SelectedEntry) => void; onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void; onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
} }
@@ -18,14 +19,14 @@
let { info, onclick, onOpenMenuClick }: Props = $props(); let { info, onclick, onOpenMenuClick }: Props = $props();
const openDirectory = () => { const openDirectory = () => {
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo; const { id, dataKey, dataKeyVersion, name } = $info.data as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "directory", id, dataKey, dataKeyVersion, name }); onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
}; };
const openMenu = () => { const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo; const { id, dataKey, dataKeyVersion, name } = $info.data as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name }); onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
@@ -39,6 +40,6 @@
actionButtonIcon={IconMoreVert} actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu} onActionButtonClick={openMenu}
> >
<DirectoryEntryLabel type="directory" name={$info.name!} /> <DirectoryEntryLabel type="directory" name={$info.data?.name!} />
</ActionEntryButton> </ActionEntryButton>
{/if} {/if}

View File

@@ -1,6 +1,10 @@
import { error, text } from "@sveltejs/kit"; import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth"; import { authorize } from "$lib/server/modules/auth";
import { directoryCreateRequest } from "$lib/server/schemas"; import {
directoryCreateRequest,
directoryCreateResponse,
type DirectoryCreateResponse,
} from "$lib/server/schemas";
import { createDirectory } from "$lib/server/services/directory"; import { createDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
@@ -11,7 +15,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data; const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
await createDirectory({ const { id } = await createDirectory({
userId, userId,
parentId: parent, parentId: parent,
mekVersion, mekVersion,
@@ -19,5 +23,5 @@ export const POST: RequestHandler = async ({ locals, request }) => {
dekVersion: new Date(dekVersion), dekVersion: new Date(dekVersion),
encName: { ciphertext: name, iv: nameIv }, encName: { ciphertext: name, iv: nameIv },
}); });
return text("Directory created", { headers: { "Content-Type": "text/plain" } }); return json(directoryCreateResponse.parse({ directory: id } satisfies DirectoryCreateResponse));
}; };