디렉터리 관련 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);
};
export const updateDirectoryInfo = async (id: number, changes: { name?: string }) => {
await filesystem.directory.update(id, changes);
};
export const deleteDirectoryInfo = async (id: number) => {
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 const registerDirectory = async (params: NewDirectory) => {
await db.transaction().execute(async (trx) => {
return await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
@@ -73,6 +73,7 @@ export const registerDirectory = async (params: NewDirectory) => {
new_name: params.encName,
})
.execute();
return { id: directoryId };
});
};

View File

@@ -39,3 +39,8 @@ export const directoryCreateRequest = z.object({
nameIv: z.string().base64().nonempty(),
});
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 {
await registerDirectory(params);
const { id } = await registerDirectory(params);
return { id };
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
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 { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
@@ -10,7 +11,7 @@
type SubDirectoryInfo = DirectoryInfo & { id: number };
interface Props {
info: Writable<DirectoryInfo | null>;
info: DirectoryInfoStore;
onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
}
@@ -18,14 +19,14 @@
let { info, onclick, onOpenMenuClick }: Props = $props();
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
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
};
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
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
@@ -39,6 +40,6 @@
actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu}
>
<DirectoryEntryLabel type="directory" name={$info.name!} />
<DirectoryEntryLabel type="directory" name={$info.data?.name!} />
</ActionEntryButton>
{/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 { directoryCreateRequest } from "$lib/server/schemas";
import {
directoryCreateRequest,
directoryCreateResponse,
type DirectoryCreateResponse,
} from "$lib/server/schemas";
import { createDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types";
@@ -11,7 +15,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
if (!zodRes.success) error(400, "Invalid request body");
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
await createDirectory({
const { id } = await createDirectory({
userId,
parentId: parent,
mekVersion,
@@ -19,5 +23,5 @@ export const POST: RequestHandler = async ({ locals, request }) => {
dekVersion: new Date(dekVersion),
encName: { ciphertext: name, iv: nameIv },
});
return text("Directory created", { headers: { "Content-Type": "text/plain" } });
return json(directoryCreateResponse.parse({ directory: id } satisfies DirectoryCreateResponse));
};