mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-03 23:56:53 +00:00
파일, 카테고리, 디렉터리 정보를 불러올 때 특정 조건에서 네트워크 요청이 여러 번 발생할 수 있는 버그 수정
This commit is contained in:
@@ -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
28
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -1,167 +1,121 @@
|
|||||||
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?.files
|
||||||
const files = category
|
|
||||||
? await Promise.all(
|
|
||||||
category.files.map(async (file) => {
|
|
||||||
const fileInfo = await IndexedDB.getFileInfo(file.id);
|
|
||||||
return fileInfo
|
|
||||||
? {
|
|
||||||
id: file.id,
|
|
||||||
contentType: fileInfo.contentType,
|
|
||||||
name: fileInfo.name,
|
|
||||||
createdAt: fileInfo.createdAt,
|
|
||||||
lastModifiedAt: fileInfo.lastModifiedAt,
|
|
||||||
isRecursive: file.isRecursive,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (id === "root") {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true as const,
|
|
||||||
subCategories,
|
|
||||||
};
|
|
||||||
} else if (category) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true as const,
|
|
||||||
name: category.name,
|
|
||||||
subCategories,
|
|
||||||
files: files!.filter((file) => !!file),
|
|
||||||
isFileRecursive: category.isFileRecursive,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
metadata,
|
|
||||||
subCategories: subCategoriesRaw,
|
|
||||||
files: filesRaw,
|
|
||||||
} = 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,
|
|
||||||
parentId: id,
|
|
||||||
name: decrypted.name,
|
|
||||||
files: existing?.files ?? [],
|
|
||||||
isFileRecursive: existing?.isFileRecursive ?? false,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: category.id,
|
|
||||||
...decrypted,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const existingFiles = filesRaw
|
|
||||||
? await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id))
|
|
||||||
: [];
|
|
||||||
const files = filesRaw
|
|
||||||
? await Promise.all(
|
? await Promise.all(
|
||||||
filesRaw.map(async (file, index) => {
|
category.files.map(async (file) => {
|
||||||
const decrypted = await decryptFileMetadata(file, masterKey);
|
const fileInfo = await IndexedDB.getFileInfo(file.id);
|
||||||
const existing = existingFiles[index];
|
return fileInfo
|
||||||
if (existing) {
|
? {
|
||||||
const categoryIds = file.isRecursive
|
id: file.id,
|
||||||
? existing.categoryIds
|
parentId: fileInfo.parentId,
|
||||||
: Array.from(new Set([...existing.categoryIds, id as number]));
|
contentType: fileInfo.contentType,
|
||||||
await IndexedDB.storeFileInfo({
|
name: fileInfo.name,
|
||||||
id: file.id,
|
createdAt: fileInfo.createdAt,
|
||||||
parentId: existing.parentId,
|
lastModifiedAt: fileInfo.lastModifiedAt,
|
||||||
contentType: file.contentType,
|
isRecursive: file.isRecursive,
|
||||||
name: decrypted.name,
|
}
|
||||||
createdAt: decrypted.createdAt,
|
: undefined;
|
||||||
lastModifiedAt: decrypted.lastModifiedAt,
|
|
||||||
categoryIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: file.id,
|
|
||||||
contentType: file.contentType,
|
|
||||||
isRecursive: file.isRecursive,
|
|
||||||
...decrypted,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
: undefined;
|
: 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") {
|
if (id === "root") {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
exists: true as const,
|
exists: true,
|
||||||
subCategories,
|
subCategories,
|
||||||
};
|
};
|
||||||
} else {
|
} else if (category) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
exists: true as const,
|
exists: true,
|
||||||
|
parentId: category.parentId,
|
||||||
|
name: category.name,
|
||||||
subCategories,
|
subCategories,
|
||||||
files,
|
files: files?.filter((file) => !!file) ?? [],
|
||||||
...decryptedMetadata!,
|
isFileRecursive: category.isFileRecursive ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
|
||||||
await IndexedDB.deleteCategoryInfo(id as number);
|
async fetchFromServer(id, cachedInfo, masterKey) {
|
||||||
return { id, exists: false as const };
|
try {
|
||||||
|
const category = await trpc().category.get.query({ id, recurse: true });
|
||||||
|
const [subCategories, files, metadata] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
category.subCategories.map(async (category) => ({
|
||||||
|
id: category.id,
|
||||||
|
parentId: id,
|
||||||
|
...(await decryptCategoryMetadata(category, masterKey)),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
category.files &&
|
||||||
|
Promise.all(
|
||||||
|
category.files.map(async (file) => ({
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||||
|
await IndexedDB.deleteCategoryInfo(id as number);
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,125 +1,102 @@
|
|||||||
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),
|
IndexedDB.getFileInfos(id),
|
||||||
IndexedDB.getFileInfos(id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (id === "root") {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true as const,
|
|
||||||
subDirectories,
|
|
||||||
files,
|
|
||||||
};
|
|
||||||
} else if (directory) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true as const,
|
|
||||||
parentId: directory.parentId,
|
|
||||||
name: directory.name,
|
|
||||||
subDirectories,
|
|
||||||
files,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
metadata,
|
|
||||||
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(
|
|
||||||
subDirectoriesRaw.map(async (directory) => {
|
|
||||||
const decrypted = await decryptDirectoryMetadata(directory, masterKey);
|
|
||||||
await IndexedDB.storeDirectoryInfo({
|
|
||||||
id: directory.id,
|
|
||||||
parentId: id,
|
|
||||||
name: decrypted.name,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: directory.id,
|
|
||||||
...decrypted,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Promise.all(
|
|
||||||
filesRaw.map(async (file, index) => {
|
|
||||||
const decrypted = await decryptFileMetadata(file, masterKey);
|
|
||||||
await IndexedDB.storeFileInfo({
|
|
||||||
id: file.id,
|
|
||||||
parentId: id,
|
|
||||||
contentType: file.contentType,
|
|
||||||
name: decrypted.name,
|
|
||||||
createdAt: decrypted.createdAt,
|
|
||||||
lastModifiedAt: decrypted.lastModifiedAt,
|
|
||||||
categoryIds: existingFiles[index]?.categoryIds ?? [],
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: file.id,
|
|
||||||
contentType: file.contentType,
|
|
||||||
...decrypted,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
metadata ? decryptDirectoryMetadata(metadata, masterKey) : undefined,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (id !== "root" && metadata && decryptedMetadata) {
|
|
||||||
await IndexedDB.storeDirectoryInfo({
|
|
||||||
id,
|
|
||||||
parentId: metadata.parent,
|
|
||||||
name: decryptedMetadata.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id === "root") {
|
if (id === "root") {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
exists: true as const,
|
exists: true,
|
||||||
subDirectories,
|
subDirectories,
|
||||||
files,
|
files,
|
||||||
};
|
};
|
||||||
} else {
|
} else if (directory) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
exists: true as const,
|
exists: true,
|
||||||
parentId: metadata!.parent,
|
parentId: directory.parentId,
|
||||||
|
name: directory.name,
|
||||||
subDirectories,
|
subDirectories,
|
||||||
files,
|
files,
|
||||||
...decryptedMetadata!,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
|
||||||
await IndexedDB.deleteDirectoryInfo(id as number);
|
async fetchFromServer(id, _cachedInfo, masterKey) {
|
||||||
return { id, exists: false as const };
|
try {
|
||||||
|
const directory = await trpc().directory.get.query({ id });
|
||||||
|
const [subDirectories, files, metadata] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
directory.subDirectories.map(async (directory) => ({
|
||||||
|
id: directory.id,
|
||||||
|
parentId: id,
|
||||||
|
...(await decryptDirectoryMetadata(directory, masterKey)),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
Promise.all(
|
||||||
|
directory.files.map(async (file) => ({
|
||||||
|
id: file.id,
|
||||||
|
parentId: id,
|
||||||
|
contentType: file.contentType,
|
||||||
|
...(await decryptFileMetadata(file, masterKey)),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return storeToIndexedDB(
|
||||||
|
id !== "root"
|
||||||
|
? {
|
||||||
|
id,
|
||||||
|
parentId: directory.metadata!.parent,
|
||||||
|
subDirectories,
|
||||||
|
files,
|
||||||
|
...metadata!,
|
||||||
|
}
|
||||||
|
: { id, subDirectories, files },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||||
|
await IndexedDB.deleteDirectoryInfo(id as number);
|
||||||
|
return { id, exists: false as const };
|
||||||
|
}
|
||||||
|
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,175 +1,177 @@
|
|||||||
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 file = await IndexedDB.getFileInfo(id);
|
||||||
|
const categories = 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;
|
||||||
|
|
||||||
const fetchFromIndexedDB = async (id: number) => {
|
if (file) {
|
||||||
const file = await IndexedDB.getFileInfo(id);
|
return {
|
||||||
const categories = file
|
id,
|
||||||
? await Promise.all(
|
exists: true,
|
||||||
file.categoryIds.map(async (categoryId) => {
|
parentId: file.parentId,
|
||||||
const category = await IndexedDB.getCategoryInfo(categoryId);
|
contentType: file.contentType,
|
||||||
return category ? { id: category.id, name: category.name } : undefined;
|
name: file.name,
|
||||||
}),
|
createdAt: file.createdAt,
|
||||||
)
|
lastModifiedAt: file.lastModifiedAt,
|
||||||
: undefined;
|
categories: categories?.filter((category) => !!category) ?? [],
|
||||||
|
};
|
||||||
if (file) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true as const,
|
|
||||||
parentId: file.parentId,
|
|
||||||
contentType: file.contentType,
|
|
||||||
name: file.name,
|
|
||||||
createdAt: file.createdAt,
|
|
||||||
lastModifiedAt: file.lastModifiedAt,
|
|
||||||
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) => {
|
|
||||||
try {
|
|
||||||
const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id });
|
|
||||||
const [categories, decryptedMetadata] = await Promise.all([
|
|
||||||
Promise.all(
|
|
||||||
categoriesRaw.map(async (category) => ({
|
|
||||||
id: category.id,
|
|
||||||
...(await decryptCategoryMetadata(category, masterKey)),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
decryptFileMetadata(metadata, masterKey),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await IndexedDB.storeFileInfo({
|
|
||||||
id,
|
|
||||||
parentId: metadata.parent,
|
|
||||||
contentType: metadata.contentType,
|
|
||||||
name: decryptedMetadata.name,
|
|
||||||
createdAt: decryptedMetadata.createdAt,
|
|
||||||
lastModifiedAt: decryptedMetadata.lastModifiedAt,
|
|
||||||
categoryIds: categories.map((category) => category.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
exists: true as const,
|
|
||||||
parentId: metadata.parent,
|
|
||||||
contentType: metadata.contentType,
|
|
||||||
contentIv: metadata.contentIv,
|
|
||||||
categories,
|
|
||||||
...decryptedMetadata,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
|
||||||
await IndexedDB.deleteFileInfo(id);
|
|
||||||
return { id, exists: false as const };
|
|
||||||
}
|
}
|
||||||
throw e;
|
},
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bulkFetchFromServer = async (ids: number[], masterKey: CryptoKey) => {
|
async fetchFromServer(id, _cachedInfo, masterKey) {
|
||||||
const filesRaw = await trpc().file.bulkGet.query({ ids });
|
try {
|
||||||
const files = await Promise.all(
|
const file = await trpc().file.get.query({ id });
|
||||||
filesRaw.map(async (file) => {
|
const [categories, metadata] = await Promise.all([
|
||||||
const [categories, decryptedMetadata] = await Promise.all([
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
file.categories.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(file, masterKey),
|
decryptFileMetadata(file, masterKey),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await IndexedDB.storeFileInfo({
|
return storeToIndexedDB({
|
||||||
id: file.id,
|
id,
|
||||||
parentId: file.parent,
|
|
||||||
contentType: file.contentType,
|
|
||||||
name: decryptedMetadata.name,
|
|
||||||
createdAt: decryptedMetadata.createdAt,
|
|
||||||
lastModifiedAt: decryptedMetadata.lastModifiedAt,
|
|
||||||
categoryIds: categories.map((category) => category.id),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: file.id,
|
|
||||||
exists: true as const,
|
|
||||||
parentId: file.parent,
|
parentId: file.parent,
|
||||||
|
dataKey: metadata.dataKey,
|
||||||
contentType: file.contentType,
|
contentType: file.contentType,
|
||||||
contentIv: file.contentIv,
|
contentIv: file.contentIv,
|
||||||
|
name: metadata.name,
|
||||||
|
createdAt: metadata.createdAt,
|
||||||
|
lastModifiedAt: metadata.lastModifiedAt,
|
||||||
categories,
|
categories,
|
||||||
...decryptedMetadata,
|
});
|
||||||
};
|
} catch (e) {
|
||||||
}),
|
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||||
);
|
await IndexedDB.deleteFileInfo(id);
|
||||||
|
return { id, exists: false as const };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
const existingIds = new Set(filesRaw.map(({ id }) => id));
|
async bulkFetchFromIndexedDB(ids) {
|
||||||
return new Map<number, MaybeFileInfo>([
|
const files = await IndexedDB.bulkGetFileInfos([...ids]);
|
||||||
...files.map((file) => [file.id, file] as const),
|
const categories = await Promise.all(
|
||||||
...ids.filter((id) => !existingIds.has(id)).map((id) => [id, { id, exists: false }] as const),
|
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(
|
||||||
|
filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
|
||||||
|
const [categories, metadata] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
categoriesRaw.map(async (category) => ({
|
||||||
|
id: category.id,
|
||||||
|
parentId: category.parent,
|
||||||
|
...(await decryptCategoryMetadata(category, masterKey)),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
decryptFileMetadata(metadataRaw, masterKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
exists: true as const,
|
||||||
|
parentId: metadataRaw.parent,
|
||||||
|
contentType: metadataRaw.contentType,
|
||||||
|
contentIv: metadataRaw.contentIv,
|
||||||
|
categories,
|
||||||
|
...metadata,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingIds = new Set(filesRaw.map(({ id }) => id));
|
||||||
|
|
||||||
|
return new Map<number, MaybeFileInfo>([
|
||||||
|
...bulkStoreToIndexedDB(files),
|
||||||
|
...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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
|
||||||
|
bulkFetchFromServer?: (
|
||||||
|
keys: Map<K, { cachedValue: V | undefined }>,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
) => Promise<Map<K, V>>;
|
||||||
|
}
|
||||||
|
|
||||||
get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) {
|
export class FilesystemCache<K, V extends object> {
|
||||||
const info = this.map.get(key);
|
private map = new Map<K, { value?: V; promise?: Promise<V> }>();
|
||||||
if (info instanceof Promise) {
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { promise, resolve } = Promise.withResolvers<V>();
|
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
|
||||||
if (!info) {
|
|
||||||
this.map.set(key, promise);
|
|
||||||
}
|
|
||||||
|
|
||||||
loader(!info, (loadedInfo) => {
|
get(key: K, masterKey: CryptoKey) {
|
||||||
if (!loadedInfo) return;
|
return untrack(() => {
|
||||||
|
let state = this.map.get(key);
|
||||||
|
if (state?.promise) return state.value ?? state.promise;
|
||||||
|
|
||||||
const info = this.map.get(key)!;
|
const { promise: newPromise, resolve } = Promise.withResolvers<V>();
|
||||||
if (info instanceof Promise) {
|
|
||||||
const state = $state(loadedInfo);
|
if (!state) {
|
||||||
this.map.set(key, state as V);
|
const newState = $state({});
|
||||||
resolve(state as V);
|
state = newState;
|
||||||
} else {
|
this.map.set(key, newState);
|
||||||
Object.assign(info, loadedInfo);
|
|
||||||
resolve(info);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return info ?? promise;
|
state.promise = newPromise;
|
||||||
|
|
||||||
|
(state.value
|
||||||
|
? Promise.resolve(state.value)
|
||||||
|
: this.options.fetchFromIndexedDB(key).then((loadedInfo) => {
|
||||||
|
if (loadedInfo) {
|
||||||
|
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 {
|
||||||
|
state.value = loadedInfo;
|
||||||
|
}
|
||||||
|
resolve(state.value);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
state.promise = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newPromise;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkGet(
|
bulkGet(keys: Set<K>, masterKey: CryptoKey) {
|
||||||
keys: Set<K>,
|
return untrack(() => {
|
||||||
loader: (keys: Map<K, boolean>, resolve: (values: Map<K, RV>) => void) => void,
|
const newPromises = new Map(
|
||||||
) {
|
keys
|
||||||
const states = new Map<K, V>();
|
.keys()
|
||||||
const promises = new Map<K, Promise<V>>();
|
.filter((key) => this.map.get(key)?.promise === undefined)
|
||||||
const resolvers = new Map<K, (value: V) => void>();
|
.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
keys.forEach((key) => {
|
const resolve = (loadedInfos: Map<K, V>) => {
|
||||||
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)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">>);
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user