파일, 카테고리, 디렉터리 정보를 불러올 때 특정 조건에서 네트워크 요청이 여러 번 발생할 수 있는 버그 수정

This commit is contained in:
static
2026-01-05 06:49:12 +09:00
parent f10a0a2da3
commit ae1d34fc6b
14 changed files with 467 additions and 501 deletions

View File

@@ -22,7 +22,7 @@
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2", "@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tanstack/svelte-virtual": "^3.13.15", "@tanstack/svelte-virtual": "^3.13.16",
"@trpc/client": "^11.8.1", "@trpc/client": "^11.8.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
@@ -64,7 +64,7 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.3.4" "zod": "^4.3.5"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",

28
pnpm-lock.yaml generated
View File

@@ -36,8 +36,8 @@ importers:
specifier: ^13.0.0 specifier: ^13.0.0
version: 13.0.0 version: 13.0.0
zod: zod:
specifier: ^4.3.4 specifier: ^4.3.5
version: 4.3.4 version: 4.3.5
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^2.0.0 specifier: ^2.0.0
@@ -58,8 +58,8 @@ importers:
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0)) version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0))
'@tanstack/svelte-virtual': '@tanstack/svelte-virtual':
specifier: ^3.13.15 specifier: ^3.13.16
version: 3.13.15(svelte@5.46.1) version: 3.13.16(svelte@5.46.1)
'@trpc/client': '@trpc/client':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3) version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)
@@ -620,13 +620,13 @@ packages:
svelte: ^5.0.0 svelte: ^5.0.0
vite: ^6.3.0 || ^7.0.0 vite: ^6.3.0 || ^7.0.0
'@tanstack/svelte-virtual@3.13.15': '@tanstack/svelte-virtual@3.13.16':
resolution: {integrity: sha512-3PPLI3hsyT70zSZhBkSIZXIarlN+GjFNKeKr2Wk1UR7EuEVtXgNlB/Zk0sYtaeJ4CvGvldQNakOvbdETnWAgeA==} resolution: {integrity: sha512-LRDPRzAPTIiDjiCA9lhNlFnZRLj/XsNhzNRsT5JEA8hzcBmZw8avdYYVjydPAy0ObFJgG1zBAm9Dtvwqju36sg==}
peerDependencies: peerDependencies:
svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 svelte: ^3.48.0 || ^4.0.0 || ^5.0.0
'@tanstack/virtual-core@3.13.15': '@tanstack/virtual-core@3.13.16':
resolution: {integrity: sha512-8cG3acM2cSIm3h8WxboHARAhQAJbYUhvmadvnN8uz8aziDwrbYb9KiARni+uY2qrLh49ycn+poGoxvtIAKhjog==} resolution: {integrity: sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w==}
'@trpc/client@11.8.1': '@trpc/client@11.8.1':
resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==} resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==}
@@ -2025,8 +2025,8 @@ packages:
zimmerframe@1.1.4: zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zod@4.3.4: zod@4.3.5:
resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
snapshots: snapshots:
@@ -2389,12 +2389,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@tanstack/svelte-virtual@3.13.15(svelte@5.46.1)': '@tanstack/svelte-virtual@3.13.16(svelte@5.46.1)':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.15 '@tanstack/virtual-core': 3.13.16
svelte: 5.46.1 svelte: 5.46.1
'@tanstack/virtual-core@3.13.15': {} '@tanstack/virtual-core@3.13.16': {}
'@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)': '@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)':
dependencies: dependencies:
@@ -3707,4 +3707,4 @@ snapshots:
zimmerframe@1.1.4: {} zimmerframe@1.1.4: {}
zod@4.3.4: {} zod@4.3.5: {}

View File

@@ -25,7 +25,7 @@
let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props(); let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
let categoriesWithName = $derived(sortEntries($state.snapshot(categories))); let categoriesWithName = $derived(sortEntries([...categories]));
</script> </script>
{#if categoriesWithName.length > 0} {#if categoriesWithName.length > 0}

View File

@@ -13,15 +13,15 @@ interface FileInfo {
contentType: string; contentType: string;
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
categoryIds: number[]; categoryIds?: number[];
} }
interface CategoryInfo { interface CategoryInfo {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;
name: string; name: string;
files: { id: number; isRecursive: boolean }[]; files?: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean; isFileRecursive?: boolean;
} }
const filesystem = new Dexie("filesystem") as Dexie & { const filesystem = new Dexie("filesystem") as Dexie & {
@@ -55,7 +55,7 @@ export const getDirectoryInfo = async (id: number) => {
}; };
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => { export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.put(directoryInfo); await filesystem.directory.upsert(directoryInfo.id, { ...directoryInfo });
}; };
export const deleteDirectoryInfo = async (id: number) => { export const deleteDirectoryInfo = async (id: number) => {
@@ -89,7 +89,7 @@ export const bulkGetFileInfos = async (ids: number[]) => {
}; };
export const storeFileInfo = async (fileInfo: FileInfo) => { export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo); await filesystem.file.upsert(fileInfo.id, { ...fileInfo });
}; };
export const deleteFileInfo = async (id: number) => { export const deleteFileInfo = async (id: number) => {
@@ -112,7 +112,7 @@ export const getCategoryInfo = async (id: number) => {
}; };
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => { export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo); await filesystem.category.upsert(categoryInfo.id, { ...categoryInfo });
}; };
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => { export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {

View File

@@ -1,22 +1,22 @@
import * as IndexedDB from "$lib/indexedDB"; import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { MaybeCategoryInfo } from "./types"; import type { CategoryInfo, MaybeCategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo, Partial<MaybeCategoryInfo>>(); const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
async fetchFromIndexedDB(id) {
const fetchFromIndexedDB = async (id: CategoryId) => {
const [category, subCategories] = await Promise.all([ const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined, id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id), IndexedDB.getCategoryInfos(id),
]); ]);
const files = category const files = category?.files
? await Promise.all( ? await Promise.all(
category.files.map(async (file) => { category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id); const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo return fileInfo
? { ? {
id: file.id, id: file.id,
parentId: fileInfo.parentId,
contentType: fileInfo.contentType, contentType: fileInfo.contentType,
name: fileInfo.name, name: fileInfo.name,
createdAt: fileInfo.createdAt, createdAt: fileInfo.createdAt,
@@ -31,137 +31,91 @@ const fetchFromIndexedDB = async (id: CategoryId) => {
if (id === "root") { if (id === "root") {
return { return {
id, id,
exists: true as const, exists: true,
subCategories, subCategories,
}; };
} else if (category) { } else if (category) {
return { return {
id, id,
exists: true as const, exists: true,
parentId: category.parentId,
name: category.name, name: category.name,
subCategories, subCategories,
files: files!.filter((file) => !!file), files: files?.filter((file) => !!file) ?? [],
isFileRecursive: category.isFileRecursive, isFileRecursive: category.isFileRecursive ?? false,
}; };
} }
}; },
const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => { async fetchFromServer(id, cachedInfo, masterKey) {
try { try {
const { const category = await trpc().category.get.query({ id, recurse: true });
metadata, const [subCategories, files, metadata] = await Promise.all([
subCategories: subCategoriesRaw, Promise.all(
files: filesRaw, category.subCategories.map(async (category) => ({
} = await trpc().category.get.query({ id, recurse: true });
void IndexedDB.deleteDanglingCategoryInfos(id, new Set(subCategoriesRaw.map(({ id }) => id)));
const subCategories = await Promise.all(
subCategoriesRaw.map(async (category) => {
const decrypted = await decryptCategoryMetadata(category, masterKey);
const existing = await IndexedDB.getCategoryInfo(category.id);
await IndexedDB.storeCategoryInfo({
id: category.id, id: category.id,
parentId: id, parentId: id,
name: decrypted.name, ...(await decryptCategoryMetadata(category, masterKey)),
files: existing?.files ?? [], })),
isFileRecursive: existing?.isFileRecursive ?? false, ),
}); category.files &&
return { Promise.all(
id: category.id, category.files.map(async (file) => ({
...decrypted, id: file.id,
}; parentId: file.parent,
}), contentType: file.contentType,
isRecursive: file.isRecursive,
...(await decryptFileMetadata(file, masterKey)),
})),
),
category.metadata && decryptCategoryMetadata(category.metadata, masterKey),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: category.metadata!.parent,
subCategories,
files: files!,
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
...metadata!,
}
: { id, subCategories },
); );
const existingFiles = filesRaw
? await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id))
: [];
const files = filesRaw
? await Promise.all(
filesRaw.map(async (file, index) => {
const decrypted = await decryptFileMetadata(file, masterKey);
const existing = existingFiles[index];
if (existing) {
const categoryIds = file.isRecursive
? existing.categoryIds
: Array.from(new Set([...existing.categoryIds, id as number]));
await IndexedDB.storeFileInfo({
id: file.id,
parentId: existing.parentId,
contentType: file.contentType,
name: decrypted.name,
createdAt: decrypted.createdAt,
lastModifiedAt: decrypted.lastModifiedAt,
categoryIds,
});
}
return {
id: file.id,
contentType: file.contentType,
isRecursive: file.isRecursive,
...decrypted,
};
}),
)
: undefined;
const decryptedMetadata = metadata
? await decryptCategoryMetadata(metadata, masterKey)
: undefined;
if (id !== "root" && metadata && decryptedMetadata) {
const existingCategory = await IndexedDB.getCategoryInfo(id);
await IndexedDB.storeCategoryInfo({
id: id as number,
parentId: metadata.parent,
name: decryptedMetadata.name,
files:
files?.map((file) => ({
id: file.id,
isRecursive: file.isRecursive,
})) ??
existingCategory?.files ??
[],
isFileRecursive: existingCategory?.isFileRecursive ?? false,
});
}
if (id === "root") {
return {
id,
exists: true as const,
subCategories,
};
} else {
return {
id,
exists: true as const,
subCategories,
files,
...decryptedMetadata!,
};
}
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number); await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false as const }; return { id, exists: false };
} }
throw e; throw e;
} }
},
});
const storeToIndexedDB = (info: CategoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeCategoryInfo(info);
// TODO: Bulk Upsert
new Map(info.files.map((file) => [file.id, file])).forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
}
// TODO: Bulk Upsert
info.subCategories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
void IndexedDB.deleteDanglingCategoryInfos(
info.id,
new Set(info.subCategories.map(({ id }) => id)),
);
return { ...info, exists: true as const };
}; };
export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => { export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => {
return await cache.get(id, async (isInitial, resolve) => { return await cache.get(id, masterKey);
if (isInitial) {
const info = await fetchFromIndexedDB(id);
if (info) {
resolve(info);
}
}
const info = await fetchFromServer(id, masterKey);
if (info) {
resolve(info);
}
});
}; };

View File

@@ -1,12 +1,10 @@
import * as IndexedDB from "$lib/indexedDB"; import * as IndexedDB from "$lib/indexedDB";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte"; import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
import type { MaybeDirectoryInfo } from "./types"; import type { DirectoryInfo, MaybeDirectoryInfo } from "./types";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>(); const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
async fetchFromIndexedDB(id) {
const fetchFromIndexedDB = async (id: DirectoryId) => {
const [directory, subDirectories, files] = await Promise.all([ const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined, id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
IndexedDB.getDirectoryInfos(id), IndexedDB.getDirectoryInfos(id),
@@ -16,96 +14,55 @@ const fetchFromIndexedDB = async (id: DirectoryId) => {
if (id === "root") { if (id === "root") {
return { return {
id, id,
exists: true as const, exists: true,
subDirectories, subDirectories,
files, files,
}; };
} else if (directory) { } else if (directory) {
return { return {
id, id,
exists: true as const, exists: true,
parentId: directory.parentId, parentId: directory.parentId,
name: directory.name, name: directory.name,
subDirectories, subDirectories,
files, files,
}; };
} }
}; },
const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => { async fetchFromServer(id, _cachedInfo, masterKey) {
try { try {
const { const directory = await trpc().directory.get.query({ id });
metadata, const [subDirectories, files, metadata] = await Promise.all([
subDirectories: subDirectoriesRaw,
files: filesRaw,
} = await trpc().directory.get.query({ id });
void IndexedDB.deleteDanglingDirectoryInfos(id, new Set(subDirectoriesRaw.map(({ id }) => id)));
void IndexedDB.deleteDanglingFileInfos(id, new Set(filesRaw.map(({ id }) => id)));
const existingFiles = await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id));
const [subDirectories, files, decryptedMetadata] = await Promise.all([
Promise.all( Promise.all(
subDirectoriesRaw.map(async (directory) => { directory.subDirectories.map(async (directory) => ({
const decrypted = await decryptDirectoryMetadata(directory, masterKey);
await IndexedDB.storeDirectoryInfo({
id: directory.id, id: directory.id,
parentId: id, parentId: id,
name: decrypted.name, ...(await decryptDirectoryMetadata(directory, masterKey)),
}); })),
return {
id: directory.id,
...decrypted,
};
}),
), ),
Promise.all( Promise.all(
filesRaw.map(async (file, index) => { directory.files.map(async (file) => ({
const decrypted = await decryptFileMetadata(file, masterKey);
await IndexedDB.storeFileInfo({
id: file.id, id: file.id,
parentId: id, parentId: id,
contentType: file.contentType, contentType: file.contentType,
name: decrypted.name, ...(await decryptFileMetadata(file, masterKey)),
createdAt: decrypted.createdAt, })),
lastModifiedAt: decrypted.lastModifiedAt,
categoryIds: existingFiles[index]?.categoryIds ?? [],
});
return {
id: file.id,
contentType: file.contentType,
...decrypted,
};
}),
), ),
metadata ? decryptDirectoryMetadata(metadata, masterKey) : undefined, directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey),
]); ]);
if (id !== "root" && metadata && decryptedMetadata) { return storeToIndexedDB(
await IndexedDB.storeDirectoryInfo({ id !== "root"
? {
id, id,
parentId: metadata.parent, parentId: directory.metadata!.parent,
name: decryptedMetadata.name,
});
}
if (id === "root") {
return {
id,
exists: true as const,
subDirectories, subDirectories,
files, files,
}; ...metadata!,
} else {
return {
id,
exists: true as const,
parentId: metadata!.parent,
subDirectories,
files,
...decryptedMetadata!,
};
} }
: { id, subDirectories, files },
);
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteDirectoryInfo(id as number); await IndexedDB.deleteDirectoryInfo(id as number);
@@ -113,13 +70,33 @@ const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => {
} }
throw e; throw e;
} }
},
});
const storeToIndexedDB = (info: DirectoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeDirectoryInfo(info);
}
// TODO: Bulk Upsert
info.subDirectories.forEach((subDirectory) => {
void IndexedDB.storeDirectoryInfo(subDirectory);
});
// TODO: Bulk Upsert
info.files.forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
void IndexedDB.deleteDanglingDirectoryInfos(
info.id,
new Set(info.subDirectories.map(({ id }) => id)),
);
void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id)));
return { ...info, exists: true as const };
}; };
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) => return await cache.get(id, masterKey);
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
}; };

View File

@@ -1,18 +1,18 @@
import * as IndexedDB from "$lib/indexedDB"; import * as IndexedDB from "$lib/indexedDB";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { MaybeFileInfo } from "./types"; import type { FileInfo, MaybeFileInfo } from "./types";
const cache = new FilesystemCache<number, MaybeFileInfo>(); const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromIndexedDB(id) {
const fetchFromIndexedDB = async (id: number) => {
const file = await IndexedDB.getFileInfo(id); const file = await IndexedDB.getFileInfo(id);
const categories = file const categories = file?.categoryIds
? await Promise.all( ? await Promise.all(
file.categoryIds.map(async (categoryId) => { file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId); const category = await IndexedDB.getCategoryInfo(categoryId);
return category ? { id: category.id, name: category.name } : undefined; return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}), }),
) )
: undefined; : undefined;
@@ -20,81 +20,42 @@ const fetchFromIndexedDB = async (id: number) => {
if (file) { if (file) {
return { return {
id, id,
exists: true as const, exists: true,
parentId: file.parentId, parentId: file.parentId,
contentType: file.contentType, contentType: file.contentType,
name: file.name, name: file.name,
createdAt: file.createdAt, createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt, lastModifiedAt: file.lastModifiedAt,
categories: categories!.filter((category) => !!category), categories: categories?.filter((category) => !!category) ?? [],
}; };
} }
};
const bulkFetchFromIndexedDB = async (ids: number[]) => {
const files = await IndexedDB.bulkGetFileInfos(ids);
const categories = await Promise.all(
files.map(async (file) =>
file
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category ? { id: category.id, name: category.name } : undefined;
}),
)
: undefined,
),
);
return new Map(
files
.map((file, index) =>
file
? ([
file.id,
{
...file,
exists: true,
categories: categories[index]!.filter((category) => !!category),
}, },
] as const)
: undefined,
)
.filter((file) => !!file),
);
};
const fetchFromServer = async (id: number, masterKey: CryptoKey) => { async fetchFromServer(id, _cachedInfo, masterKey) {
try { try {
const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id }); const file = await trpc().file.get.query({ id });
const [categories, decryptedMetadata] = await Promise.all([ const [categories, metadata] = await Promise.all([
Promise.all( Promise.all(
categoriesRaw.map(async (category) => ({ file.categories.map(async (category) => ({
id: category.id, id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category, masterKey)), ...(await decryptCategoryMetadata(category, masterKey)),
})), })),
), ),
decryptFileMetadata(metadata, masterKey), decryptFileMetadata(file, masterKey),
]); ]);
await IndexedDB.storeFileInfo({ return storeToIndexedDB({
id, id,
parentId: metadata.parent, parentId: file.parent,
contentType: metadata.contentType, dataKey: metadata.dataKey,
name: decryptedMetadata.name, contentType: file.contentType,
createdAt: decryptedMetadata.createdAt, contentIv: file.contentIv,
lastModifiedAt: decryptedMetadata.lastModifiedAt, name: metadata.name,
categoryIds: categories.map((category) => category.id), createdAt: metadata.createdAt,
}); lastModifiedAt: metadata.lastModifiedAt,
return {
id,
exists: true as const,
parentId: metadata.parent,
contentType: metadata.contentType,
contentIv: metadata.contentIv,
categories, categories,
...decryptedMetadata, });
};
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id); await IndexedDB.deleteFileInfo(id);
@@ -102,74 +63,115 @@ const fetchFromServer = async (id: number, masterKey: CryptoKey) => {
} }
throw e; throw e;
} }
}; },
const bulkFetchFromServer = async (ids: number[], masterKey: CryptoKey) => { async bulkFetchFromIndexedDB(ids) {
const filesRaw = await trpc().file.bulkGet.query({ ids }); const files = await IndexedDB.bulkGetFileInfos([...ids]);
const categories = await Promise.all(
files.map(async (file) =>
file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined,
),
);
return new Map(
files
.filter((file) => !!file)
.map((file, index) => [
file.id,
{
...file,
exists: true,
categories: categories[index]?.filter((category) => !!category) ?? [],
},
]),
);
},
async bulkFetchFromServer(ids, masterKey) {
const idsArray = [...ids.keys()];
const filesRaw = await trpc().file.bulkGet.query({ ids: idsArray });
const files = await Promise.all( const files = await Promise.all(
filesRaw.map(async (file) => { filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
const [categories, decryptedMetadata] = await Promise.all([ const [categories, metadata] = await Promise.all([
Promise.all( Promise.all(
file.categories.map(async (category) => ({ categoriesRaw.map(async (category) => ({
id: category.id, id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category, masterKey)), ...(await decryptCategoryMetadata(category, masterKey)),
})), })),
), ),
decryptFileMetadata(file, masterKey), decryptFileMetadata(metadataRaw, masterKey),
]); ]);
await IndexedDB.storeFileInfo({
id: file.id,
parentId: file.parent,
contentType: file.contentType,
name: decryptedMetadata.name,
createdAt: decryptedMetadata.createdAt,
lastModifiedAt: decryptedMetadata.lastModifiedAt,
categoryIds: categories.map((category) => category.id),
});
return { return {
id: file.id, id,
exists: true as const, exists: true as const,
parentId: file.parent, parentId: metadataRaw.parent,
contentType: file.contentType, contentType: metadataRaw.contentType,
contentIv: file.contentIv, contentIv: metadataRaw.contentIv,
categories, categories,
...decryptedMetadata, ...metadata,
}; };
}), }),
); );
const existingIds = new Set(filesRaw.map(({ id }) => id)); const existingIds = new Set(filesRaw.map(({ id }) => id));
return new Map<number, MaybeFileInfo>([ return new Map<number, MaybeFileInfo>([
...files.map((file) => [file.id, file] as const), ...bulkStoreToIndexedDB(files),
...ids.filter((id) => !existingIds.has(id)).map((id) => [id, { id, exists: false }] as const), ...idsArray
.filter((id) => !existingIds.has(id))
.map((id) => [id, { id, exists: false }] as const),
]); ]);
},
});
const storeToIndexedDB = (info: FileInfo) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
info.categories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return { ...info, exists: true as const };
};
const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
// TODO: Bulk Upsert
infos.forEach((info) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
});
// TODO: Bulk Upsert
new Map(
infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]),
).forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
}; };
export const getFileInfo = async (id: number, masterKey: CryptoKey) => { export const getFileInfo = async (id: number, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) => return await cache.get(id, masterKey);
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
}; };
export const bulkGetFileInfo = async (ids: number[], masterKey: CryptoKey) => { export const bulkGetFileInfo = async (ids: number[], masterKey: CryptoKey) => {
return await cache.bulkGet(new Set(ids), (keys, resolve) => return await cache.bulkGet(new Set(ids), masterKey);
monotonicResolve(
[
bulkFetchFromIndexedDB(
Array.from(
keys
.entries()
.filter(([, isInitial]) => isInitial)
.map(([key]) => key),
),
),
bulkFetchFromServer(Array.from(keys.keys()), masterKey),
],
resolve,
),
);
}; };

View File

@@ -1,82 +1,120 @@
import { untrack } from "svelte";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
export class FilesystemCache<K, V extends RV, RV = V> { interface FilesystemCacheOptions<K, V> {
private map = new Map<K, V | Promise<V>>(); fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise<V>;
get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) { bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
const info = this.map.get(key); bulkFetchFromServer?: (
if (info instanceof Promise) { keys: Map<K, { cachedValue: V | undefined }>,
return info; masterKey: CryptoKey,
) => Promise<Map<K, V>>;
} }
const { promise, resolve } = Promise.withResolvers<V>(); export class FilesystemCache<K, V extends object> {
if (!info) { private map = new Map<K, { value?: V; promise?: Promise<V> }>();
this.map.set(key, promise);
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
get(key: K, masterKey: CryptoKey) {
return untrack(() => {
let state = this.map.get(key);
if (state?.promise) return state.value ?? state.promise;
const { promise: newPromise, resolve } = Promise.withResolvers<V>();
if (!state) {
const newState = $state({});
state = newState;
this.map.set(key, newState);
} }
loader(!info, (loadedInfo) => { state.promise = newPromise;
if (!loadedInfo) return;
const info = this.map.get(key)!; (state.value
if (info instanceof Promise) { ? Promise.resolve(state.value)
const state = $state(loadedInfo); : this.options.fetchFromIndexedDB(key).then((loadedInfo) => {
this.map.set(key, state as V); if (loadedInfo) {
resolve(state as V); state.value = loadedInfo;
resolve(state.value);
}
return loadedInfo;
})
)
.then((cachedInfo) => this.options.fetchFromServer(key, cachedInfo, masterKey))
.then((loadedInfo) => {
if (state.value) {
Object.assign(state.value, loadedInfo);
} else { } else {
Object.assign(info, loadedInfo); state.value = loadedInfo;
resolve(info); }
resolve(state.value);
})
.finally(() => {
state.promise = undefined;
});
return newPromise;
});
}
bulkGet(keys: Set<K>, masterKey: CryptoKey) {
return untrack(() => {
const newPromises = new Map(
keys
.keys()
.filter((key) => this.map.get(key)?.promise === undefined)
.map((key) => [key, Promise.withResolvers<V>()]),
);
newPromises.forEach(({ promise }, key) => {
const state = this.map.get(key);
if (state) {
state.promise = promise;
} else {
const newState = $state({ promise });
this.map.set(key, newState);
} }
}); });
return info ?? promise; const resolve = (loadedInfos: Map<K, V>) => {
}
async bulkGet(
keys: Set<K>,
loader: (keys: Map<K, boolean>, resolve: (values: Map<K, RV>) => void) => void,
) {
const states = new Map<K, V>();
const promises = new Map<K, Promise<V>>();
const resolvers = new Map<K, (value: V) => void>();
keys.forEach((key) => {
const info = this.map.get(key);
if (info instanceof Promise) {
promises.set(key, info);
} else if (info) {
states.set(key, info);
} else {
const { promise, resolve } = Promise.withResolvers<V>();
this.map.set(key, promise);
promises.set(key, promise);
resolvers.set(key, resolve);
}
});
loader(
new Map([
...states.keys().map((key) => [key, false] as const),
...resolvers.keys().map((key) => [key, true] as const),
]),
(loadedInfos) =>
loadedInfos.forEach((loadedInfo, key) => { loadedInfos.forEach((loadedInfo, key) => {
const info = this.map.get(key)!; const state = this.map.get(key)!;
const resolve = resolvers.get(key); if (state.value) {
if (info instanceof Promise) { Object.assign(state.value, loadedInfo);
const state = $state(loadedInfo);
this.map.set(key, state as V);
resolve?.(state as V);
} else { } else {
Object.assign(info, loadedInfo); state.value = loadedInfo;
resolve?.(info);
} }
}), newPromises.get(key)!.resolve(state.value);
); });
return loadedInfos;
};
const newStates = await Promise.all( this.options.bulkFetchFromIndexedDB!(
promises.entries().map(async ([key, promise]) => [key, await promise] as const), new Set(newPromises.keys().filter((key) => this.map.get(key)!.value === undefined)),
); )
return new Map([...states, ...newStates]); .then(resolve)
.then(() =>
this.options.bulkFetchFromServer!(
new Map(
newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]),
),
masterKey,
),
)
.then(resolve)
.finally(() => {
newPromises.forEach((_, key) => {
this.map.get(key)!.promise = undefined;
});
});
return Promise.all(
keys
.keys()
.filter((key) => this.map.get(key)!.value === undefined)
.map((key) => this.map.get(key)!.promise!),
).then(() => new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const)));
});
} }
} }

View File

@@ -20,11 +20,12 @@ interface RootDirectoryInfo {
} }
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo; export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">;
export type MaybeDirectoryInfo = export type MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true }) | (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>); | ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "subDirectories" | "files">;
export interface FileInfo { export interface FileInfo {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
@@ -34,17 +35,19 @@ export interface FileInfo {
name: string; name: string;
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
categories: { id: number; name: string }[]; categories: FileCategoryInfo[];
} }
export type SummarizedFileInfo = Omit<FileInfo, "parentId" | "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
export type MaybeFileInfo = export type MaybeFileInfo =
| (FileInfo & { exists: true }) | (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>); | ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo { interface LocalCategoryInfo {
id: number; id: number;
parentId: DirectoryId;
dataKey?: DataKey; dataKey?: DataKey;
name: string; name: string;
subCategories: SubCategoryInfo[]; subCategories: SubCategoryInfo[];
@@ -54,6 +57,7 @@ interface LocalCategoryInfo {
interface RootCategoryInfo { interface RootCategoryInfo {
id: "root"; id: "root";
parentId?: undefined;
dataKey?: undefined; dataKey?: undefined;
name?: undefined; name?: undefined;
subCategories: SubCategoryInfo[]; subCategories: SubCategoryInfo[];
@@ -62,10 +66,12 @@ interface RootCategoryInfo {
} }
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo; export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);
export type SubCategoryInfo = Omit< export type SubCategoryInfo = Omit<
LocalCategoryInfo, LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive" "subCategories" | "files" | "isFileRecursive"
>; >;
export type MaybeCategoryInfo = export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);

View File

@@ -39,6 +39,7 @@ export type NewFile = Omit<File, "id">;
interface FileCategory { interface FileCategory {
id: number; id: number;
parentId: CategoryId;
mekVersion: number; mekVersion: number;
encDek: string; encDek: string;
dekVersion: Date; dekVersion: Date;
@@ -445,6 +446,7 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[])
encLastModifiedAt: file.encrypted_last_modified_at, encLastModifiedAt: file.encrypted_last_modified_at,
categories: file.categories.map((category) => ({ categories: file.categories.map((category) => ({
id: category.id, id: category.id,
parentId: category.parent_id ?? "root",
mekVersion: category.master_encryption_key_version, mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key, encDek: category.encrypted_data_encryption_key,
dekVersion: new Date(category.data_encryption_key_version), dekVersion: new Date(category.data_encryption_key_version),
@@ -548,6 +550,7 @@ export const getAllFileCategories = async (fileId: number) => {
(category) => (category) =>
({ ({
id: category.id, id: category.id,
parentId: category.parent_id ?? "root",
mekVersion: category.master_encryption_key_version, mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key, encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version, dekVersion: category.data_encryption_key_version,

View File

@@ -1,4 +1,3 @@
export * from "./format"; export * from "./format";
export * from "./gotoStateful"; export * from "./gotoStateful";
export * from "./promise";
export * from "./sort"; export * from "./sort";

View File

@@ -1,16 +0,0 @@
export const monotonicResolve = <T>(
promises: (Promise<T> | false)[],
callback: (value: T) => void,
) => {
let latestResolvedIndex = -1;
promises
.filter((promise) => !!promise)
.forEach((promise, index) => {
promise.then((value) => {
if (index > latestResolvedIndex) {
latestResolvedIndex = index;
callback(value);
}
});
});
};

View File

@@ -46,6 +46,7 @@ const categoryRouter = router({
})), })),
files: files?.map((file) => ({ files: files?.map((file) => ({
id: file.id, id: file.id,
parent: file.parentId,
mekVersion: file.mekVersion, mekVersion: file.mekVersion,
dek: file.encDek, dek: file.encDek,
dekVersion: file.dekVersion, dekVersion: file.dekVersion,

View File

@@ -33,6 +33,7 @@ const fileRouter = router({
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
categories: categories.map((category) => ({ categories: categories.map((category) => ({
id: category.id, id: category.id,
parent: category.parentId,
mekVersion: category.mekVersion, mekVersion: category.mekVersion,
dek: category.encDek, dek: category.encDek,
dekVersion: category.dekVersion, dekVersion: category.dekVersion,
@@ -66,6 +67,7 @@ const fileRouter = router({
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
categories: file.categories.map((category) => ({ categories: file.categories.map((category) => ({
id: category.id, id: category.id,
parent: category.parentId,
mekVersion: category.mekVersion, mekVersion: category.mekVersion,
dek: category.encDek, dek: category.encDek,
dekVersion: category.dekVersion, dekVersion: category.dekVersion,