sqlite database

This commit is contained in:
m
2026-07-28 00:16:31 +02:00
parent e8ceb99a5d
commit cae6c4d72d
23 changed files with 2728 additions and 744 deletions
+433
View File
@@ -0,0 +1,433 @@
import { drizzle } from 'drizzle-orm/expo-sqlite';
import * as SQLite from 'expo-sqlite';
import { eq, like, or, and, desc, asc, sql, isNull } from 'drizzle-orm';
import { files, fileTags, deletedFiles } from './schema';
import type { Tag } from '../../types';
const DB_NAME = 'vaultdrop.db';
let _db: ReturnType<typeof drizzle> | null = null;
let _sqliteDb: SQLite.SQLiteDatabase | null = null;
export function initDB() {
if (_db) return _db;
_sqliteDb = SQLite.openDatabaseSync(DB_NAME);
_sqliteDb.execSync('PRAGMA journal_mode = WAL;');
_sqliteDb.execSync('PRAGMA foreign_keys = ON;');
_db = drizzle(_sqliteDb);
_sqliteDb.execSync(`
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
backend_id TEXT,
name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'cloud',
local_uri TEXT,
sync_status TEXT NOT NULL DEFAULT 'cloud',
parent_file_id TEXT,
is_folder INTEGER NOT NULL DEFAULT 0,
ocr_text TEXT,
thumbnail_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_synced_at TEXT
);
CREATE TABLE IF NOT EXISTS file_tags (
id TEXT PRIMARY KEY,
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
tag_name TEXT NOT NULL,
tag_type TEXT NOT NULL DEFAULT 'none'
);
CREATE INDEX IF NOT EXISTS idx_files_backend_id ON files(backend_id);
CREATE INDEX IF NOT EXISTS idx_files_parent_id ON files(parent_file_id);
CREATE INDEX IF NOT EXISTS idx_files_source ON files(source);
CREATE INDEX IF NOT EXISTS idx_files_is_folder ON files(is_folder);
CREATE INDEX IF NOT EXISTS idx_files_sync_status ON files(sync_status);
CREATE INDEX IF NOT EXISTS idx_file_tags_file_id ON file_tags(file_id);
CREATE TABLE IF NOT EXISTS deleted_files (
id TEXT PRIMARY KEY,
deleted_at TEXT NOT NULL
);
`);
return _db;
}
function getDb() {
if (!_db) initDB();
return _db!;
}
export type FileRecord = {
id: string;
backendId: string | null;
name: string;
mimeType: string;
size: number;
source: string;
localUri: string | null;
syncStatus: string;
parentFileId: string | null;
isFolder: number;
ocrText: string | null;
thumbnailUrl: string | null;
createdAt: string;
updatedAt: string;
lastSyncedAt: string | null;
tags?: Tag[];
};
type FileRow = typeof files.$inferSelect;
type TagRow = typeof fileTags.$inferSelect;
function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord {
return {
id: row.id,
backendId: row.backendId,
name: row.name,
mimeType: row.mimeType,
size: row.size,
source: row.source,
localUri: row.localUri,
syncStatus: row.syncStatus,
parentFileId: row.parentFileId,
isFolder: row.isFolder,
ocrText: row.ocrText,
thumbnailUrl: row.thumbnailUrl,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastSyncedAt: row.lastSyncedAt,
tags,
};
}
function getTagsForFile(fileId: string): Tag[] {
const d = getDb();
const rows = d.select().from(fileTags).where(eq(fileTags.fileId, fileId)).all();
return rows.map((r) => ({ id: r.tagName, tag_name: r.tagName, tag_type: r.tagType }));
}
function setTagsForFile(fileId: string, tags: Tag[]) {
const d = getDb();
d.delete(fileTags).where(eq(fileTags.fileId, fileId)).run();
if (tags.length === 0) return;
d.insert(fileTags).values(
tags.map((t) => ({
id: `${fileId}_${t.id || t.tag_name}`,
fileId,
tagName: t.tag_name,
tagType: t.tag_type,
})),
).run();
}
function upsertRow(file: FileRecord) {
const d = getDb();
d.insert(files).values({
id: file.id,
backendId: file.backendId,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: file.source,
localUri: file.localUri,
syncStatus: file.syncStatus,
parentFileId: file.parentFileId,
isFolder: file.isFolder,
ocrText: file.ocrText,
thumbnailUrl: file.thumbnailUrl,
createdAt: file.createdAt,
updatedAt: file.updatedAt,
lastSyncedAt: file.lastSyncedAt,
}).onConflictDoUpdate({
target: files.id,
set: {
backendId: file.backendId,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: file.source,
localUri: file.localUri,
syncStatus: file.syncStatus,
parentFileId: file.parentFileId,
isFolder: file.isFolder,
ocrText: file.ocrText,
thumbnailUrl: file.thumbnailUrl,
updatedAt: file.updatedAt,
lastSyncedAt: file.lastSyncedAt,
},
}).run();
}
export const fileStore = {
initDB,
upsert(file: FileRecord) {
upsertRow(file);
if (file.tags) setTagsForFile(file.id, file.tags);
},
upsertBatch(fileList: FileRecord[]) {
const d = getDb();
for (const file of fileList) {
upsertRow(file);
if (file.tags) setTagsForFile(file.id, file.tags);
}
},
getById(id: string): FileRecord | null {
const d = getDb();
const row = d.select().from(files).where(eq(files.id, id)).get();
if (!row) return null;
return rowToRecord(row, getTagsForFile(id));
},
getByBackendId(backendId: string): FileRecord | null {
const d = getDb();
const row = d.select().from(files).where(eq(files.backendId, backendId)).get();
if (!row) return null;
return rowToRecord(row, getTagsForFile(row.id));
},
getRootFolders(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(and(eq(files.isFolder, 1), isNull(files.parentFileId)))
.orderBy(asc(files.name))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getChildrenByParent(parentId: string): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.parentFileId, parentId))
.orderBy(desc(files.isFolder), desc(files.createdAt))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getPaginated(page: number, limit: number): { files: FileRecord[]; total: number } {
const d = getDb();
const offset = (page - 1) * limit;
const countRow = d.select({ count: sql<number>`count(*)` })
.from(files)
.where(and(isNull(files.parentFileId), eq(files.isFolder, 0)))
.get();
const total = countRow?.count ?? 0;
const rows = d.select().from(files)
.where(and(isNull(files.parentFileId), eq(files.isFolder, 0)))
.orderBy(desc(files.createdAt))
.limit(limit)
.offset(offset)
.all();
return {
files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))),
total,
};
},
getAllFolders(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.isFolder, 1))
.orderBy(asc(files.name))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
search(query: string): FileRecord[] {
const d = getDb();
const pattern = `%${query}%`;
const rows = d.select().from(files)
.where(or(like(files.name, pattern), like(files.ocrText, pattern)))
.orderBy(desc(files.createdAt))
.limit(100)
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
mergeFromBackend(backendFiles: Array<{
id: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
updatedAt?: string;
ocrText?: string;
tags?: Tag[];
isFolder: boolean;
parentFileId?: string;
thumbnailUrl?: string;
}>) {
const d = getDb();
const now = new Date().toISOString();
d.transaction(() => {
for (const bf of backendFiles) {
const existing = d.select().from(files).where(eq(files.backendId, bf.id)).get();
const source = existing && existing.localUri ? 'synced' : 'cloud';
const syncStatus = existing && existing.localUri
? (existing.syncStatus === 'cloud' ? 'synced' : existing.syncStatus)
: 'cloud';
upsertRow({
id: bf.id,
backendId: bf.id,
name: bf.name,
mimeType: bf.mimeType,
size: bf.size,
source,
localUri: existing?.localUri ?? null,
syncStatus,
parentFileId: bf.parentFileId ?? null,
isFolder: bf.isFolder ? 1 : 0,
ocrText: bf.ocrText ?? null,
thumbnailUrl: bf.thumbnailUrl ?? null,
createdAt: bf.createdAt,
updatedAt: bf.updatedAt ?? now,
lastSyncedAt: now,
});
if (bf.tags) setTagsForFile(bf.id, bf.tags);
}
});
},
mergeFromDevice(deviceFiles: Array<{
id: string;
uri: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
folderId?: string;
}>) {
const d = getDb();
const now = new Date().toISOString();
d.transaction(() => {
for (const df of deviceFiles) {
if (this.isDeleted(df.id)) continue;
const existing = d.select().from(files).where(eq(files.id, df.id)).get();
if (existing) continue;
upsertRow({
id: df.id,
backendId: null,
name: df.name,
mimeType: df.mimeType,
size: df.size,
source: 'local',
localUri: df.uri,
syncStatus: 'local',
parentFileId: df.folderId ?? null,
isFolder: 0,
ocrText: null,
thumbnailUrl: null,
createdAt: df.createdAt,
updatedAt: now,
lastSyncedAt: null,
});
}
});
},
updatePartial(id: string, updates: Partial<FileRecord>) {
const d = getDb();
const setFields: Record<string, unknown> = {};
if (updates.backendId !== undefined) setFields.backendId = updates.backendId;
if (updates.syncStatus !== undefined) setFields.syncStatus = updates.syncStatus;
if (updates.localUri !== undefined) setFields.localUri = updates.localUri;
if (updates.source !== undefined) setFields.source = updates.source;
if (updates.thumbnailUrl !== undefined) setFields.thumbnailUrl = updates.thumbnailUrl;
if (updates.ocrText !== undefined) setFields.ocrText = updates.ocrText;
if (updates.parentFileId !== undefined) setFields.parentFileId = updates.parentFileId;
if (updates.name !== undefined) setFields.name = updates.name;
setFields.updatedAt = new Date().toISOString();
d.update(files).set(setFields).where(eq(files.id, id)).run();
},
updateSyncStatus(id: string, syncStatus: string) {
this.updatePartial(id, { syncStatus });
},
markAsCloudOnly(id: string) {
this.updatePartial(id, { syncStatus: 'cloud', localUri: null, source: 'cloud' });
},
setThumbnailUrl(backendId: string, thumbnailUrl: string) {
const d = getDb();
d.update(files).set({ thumbnailUrl, updatedAt: new Date().toISOString() })
.where(eq(files.backendId, backendId)).run();
},
markDeleted(id: string) {
const d = getDb();
d.insert(deletedFiles).values({ id, deletedAt: new Date().toISOString() })
.onConflictDoUpdate({ target: deletedFiles.id, set: { deletedAt: new Date().toISOString() } })
.run();
},
isDeleted(id: string): boolean {
const d = getDb();
const row = d.select().from(deletedFiles).where(eq(deletedFiles.id, id)).get();
return !!row;
},
deleteById(id: string) {
const d = getDb();
this.markDeleted(id);
d.delete(files).where(eq(files.id, id)).run();
},
deleteByBackendId(backendId: string) {
const d = getDb();
const row = d.select().from(files).where(eq(files.backendId, backendId)).get();
if (row) this.markDeleted(row.id);
d.delete(files).where(eq(files.backendId, backendId)).run();
},
clear() {
const d = getDb();
d.delete(fileTags).run();
d.delete(files).run();
},
count(): number {
const d = getDb();
const row = d.select({ count: sql<number>`count(*)` }).from(files).get();
return row?.count ?? 0;
},
getAllLocal(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(or(eq(files.source, 'local'), eq(files.source, 'synced')))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getAllSynced(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.source, 'synced'))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getPendingSync(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(and(eq(files.syncStatus, 'local'), sql`${files.backendId} IS NULL`))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
};
+112
View File
@@ -0,0 +1,112 @@
import { createMMKV } from 'react-native-mmkv';
import { fileStore, FileRecord } from './index';
import type { Tag, SyncStatus } from '../../types';
const metadataStorage = createMMKV({ id: 'vaultdrop-metadata' });
const localFilesStorage = createMMKV({ id: 'vaultdrop-local-files' });
interface LegacyCachedFiles {
files: Array<{
id: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
updatedAt: string;
ocrText?: string;
tags?: Tag[];
isFolder: boolean;
parentFileId?: string;
url?: string;
thumbnailUrl?: string;
}>;
page: number;
total: number;
}
interface LegacyRegistryBlob {
entries: Record<string, {
id: string;
backendFileId?: string;
localUri: string;
name: string;
mimeType: string;
size: number;
syncStatus: SyncStatus;
createdAt: string;
tags?: Tag[];
folderId?: string;
}>;
}
export function migrateFromLegacy() {
const count = fileStore.count();
if (count > 0) return;
try {
const rawMetadata = metadataStorage.getString('backend_files_cache');
if (rawMetadata) {
const cached: LegacyCachedFiles = JSON.parse(rawMetadata);
for (const f of cached.files) {
fileStore.upsert({
id: f.id,
backendId: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
source: 'cloud',
localUri: null,
syncStatus: 'cloud',
parentFileId: f.parentFileId ?? null,
isFolder: f.isFolder ? 1 : 0,
ocrText: f.ocrText ?? null,
thumbnailUrl: f.thumbnailUrl ?? null,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
lastSyncedAt: new Date().toISOString(),
tags: f.tags,
});
}
}
} catch {}
try {
const rawRegistry = localFilesStorage.getString('local_files_v2');
if (rawRegistry) {
const blob: LegacyRegistryBlob = JSON.parse(rawRegistry);
for (const entry of Object.values(blob.entries)) {
const existing = fileStore.getByBackendId(entry.backendFileId ?? '');
const source = entry.backendFileId
? (entry.syncStatus === 'synced' ? 'synced' : 'cloud')
: 'local';
fileStore.upsert({
id: entry.id,
backendId: entry.backendFileId ?? null,
name: entry.name,
mimeType: entry.mimeType,
size: entry.size,
source,
localUri: entry.localUri,
syncStatus: entry.syncStatus,
parentFileId: entry.folderId ?? null,
isFolder: 0,
ocrText: null,
thumbnailUrl: null,
createdAt: entry.createdAt,
updatedAt: entry.createdAt,
lastSyncedAt: entry.backendFileId ? new Date().toISOString() : null,
tags: entry.tags,
});
if (existing && entry.localUri) {
fileStore.updatePartial(entry.id, {
localUri: entry.localUri,
source: 'synced',
syncStatus: 'synced',
});
}
}
}
} catch {}
}
+47
View File
@@ -0,0 +1,47 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const files = sqliteTable(
'files',
{
id: text('id').primaryKey(),
backendId: text('backend_id'),
name: text('name').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
source: text('source').notNull().default('cloud'),
localUri: text('local_uri'),
syncStatus: text('sync_status').notNull().default('cloud'),
parentFileId: text('parent_file_id'),
isFolder: integer('is_folder').notNull().default(0),
ocrText: text('ocr_text'),
thumbnailUrl: text('thumbnail_url'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
lastSyncedAt: text('last_synced_at'),
},
(t) => [
index('idx_files_backend_id').on(t.backendId),
index('idx_files_parent_id').on(t.parentFileId),
index('idx_files_source').on(t.source),
index('idx_files_is_folder').on(t.isFolder),
index('idx_files_sync_status').on(t.syncStatus),
],
);
export const fileTags = sqliteTable(
'file_tags',
{
id: text('id').primaryKey(),
fileId: text('file_id')
.notNull()
.references(() => files.id, { onDelete: 'cascade' }),
tagName: text('tag_name').notNull(),
tagType: text('tag_type').notNull().default('none'),
},
(t) => [index('idx_file_tags_file_id').on(t.fileId)],
);
export const deletedFiles = sqliteTable('deleted_files', {
id: text('id').primaryKey(),
deletedAt: text('deleted_at').notNull(),
});
-146
View File
@@ -1,146 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
import { LocalFileEntry, SyncStatus } from '../types';
const storage = createMMKV({ id: 'vaultdrop-local-files' });
const BLOB_KEY = 'local_files_v2';
const LEGACY_INDEX_KEY = 'local_files_index';
interface RegistryBlob {
entries: Record<string, LocalFileEntry>;
backendIndex: Record<string, string>;
}
let memoryCache: RegistryBlob | null = null;
function loadBlob(): RegistryBlob {
if (memoryCache) return memoryCache;
const raw = storage.getString(BLOB_KEY);
if (raw) {
memoryCache = JSON.parse(raw) as RegistryBlob;
return memoryCache;
}
memoryCache = migrateFromLegacy();
saveBlob(memoryCache);
return memoryCache;
}
function saveBlob(blob: RegistryBlob) {
memoryCache = blob;
storage.set(BLOB_KEY, JSON.stringify(blob));
}
function migrateFromLegacy(): RegistryBlob {
const blob: RegistryBlob = { entries: {}, backendIndex: {} };
const rawIndex = storage.getString(LEGACY_INDEX_KEY);
if (!rawIndex) return blob;
const ids: string[] = JSON.parse(rawIndex);
for (const id of ids) {
const raw = storage.getString(`local_file_${id}`);
if (!raw) continue;
const entry: LocalFileEntry = JSON.parse(raw);
blob.entries[entry.id] = entry;
if (entry.backendFileId) {
blob.backendIndex[entry.backendFileId] = entry.id;
}
}
storage.remove(LEGACY_INDEX_KEY);
for (const id of ids) {
storage.remove(`local_file_${id}`);
}
return blob;
}
export const localFileRegistry = {
register(entry: LocalFileEntry) {
const blob = loadBlob();
blob.entries[entry.id] = entry;
if (entry.backendFileId) {
blob.backendIndex[entry.backendFileId] = entry.id;
}
saveBlob(blob);
},
registerBatch(entries: LocalFileEntry[]) {
if (entries.length === 0) return;
const blob = loadBlob();
for (const entry of entries) {
blob.entries[entry.id] = entry;
if (entry.backendFileId) {
blob.backendIndex[entry.backendFileId] = entry.id;
}
}
saveBlob(blob);
},
get(id: string): LocalFileEntry | undefined {
return loadBlob().entries[id];
},
getByBackendId(backendId: string): LocalFileEntry | undefined {
const blob = loadBlob();
const entryId = blob.backendIndex[backendId];
if (!entryId) return undefined;
return blob.entries[entryId];
},
getAll(): LocalFileEntry[] {
const blob = loadBlob();
return Object.values(blob.entries);
},
update(id: string, updates: Partial<LocalFileEntry>) {
const blob = loadBlob();
const existing = blob.entries[id];
if (!existing) return;
if (existing.backendFileId && updates.backendFileId === undefined && updates.syncStatus === 'cloud') {
delete blob.backendIndex[existing.backendFileId];
}
const updated = { ...existing, ...updates };
blob.entries[id] = updated;
if (updated.backendFileId) {
blob.backendIndex[updated.backendFileId] = id;
}
saveBlob(blob);
},
updateSyncStatus(id: string, syncStatus: SyncStatus) {
this.update(id, { syncStatus });
},
markAsCloudOnly(id: string) {
this.update(id, { syncStatus: 'cloud', localUri: '' });
},
remove(id: string) {
const blob = loadBlob();
const entry = blob.entries[id];
if (entry?.backendFileId) {
delete blob.backendIndex[entry.backendFileId];
}
delete blob.entries[id];
saveBlob(blob);
},
removeByBackendId(backendId: string) {
const blob = loadBlob();
const entryId = blob.backendIndex[backendId];
if (entryId) {
delete blob.entries[entryId];
delete blob.backendIndex[backendId];
saveBlob(blob);
}
},
count(): number {
return Object.keys(loadBlob().entries).length;
},
};
-44
View File
@@ -1,44 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
import { FileItem } from '../types';
const storage = createMMKV({ id: 'vaultdrop-metadata' });
const FILES_KEY = 'backend_files_cache';
const UPDATED_AT_KEY = 'cache_updated_at';
const STALE_MS = 5 * 60 * 1000;
interface CachedFiles {
files: FileItem[];
page: number;
total: number;
}
export const metadataCache = {
getFiles(): CachedFiles | null {
const raw = storage.getString(FILES_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as CachedFiles;
} catch {
return null;
}
},
setFiles(files: FileItem[], page: number, total: number) {
const data: CachedFiles = { files, page, total };
storage.set(FILES_KEY, JSON.stringify(data));
storage.set(UPDATED_AT_KEY, Date.now());
},
isStale(): boolean {
const raw = storage.getString(UPDATED_AT_KEY);
if (!raw) return true;
const updatedAt = Number(raw);
return Date.now() - updatedAt > STALE_MS;
},
clear() {
storage.remove(FILES_KEY);
storage.remove(UPDATED_AT_KEY);
},
};
+20
View File
@@ -0,0 +1,20 @@
import { createMMKV } from 'react-native-mmkv';
import { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
const storage = createMMKV({ id: 'vaultdrop-query-cache' });
export function createMMKVPersister(): Persister {
return {
persistClient: async (client: PersistedClient) => {
storage.set('query-cache', JSON.stringify(client));
},
restoreClient: async () => {
const raw = storage.getString('query-cache');
if (!raw) return undefined;
return JSON.parse(raw) as PersistedClient;
},
removeClient: async () => {
storage.remove('query-cache');
},
};
}
-65
View File
@@ -1,65 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
const storage = createMMKV({ id: 'vaultdrop-thumbnails' });
const BLOB_KEY = 'thumbnail_urls';
const STALE_MS = 50 * 60 * 1000;
interface ThumbnailCacheEntry {
url: string;
expiresAt: number;
}
let memoryCache: Record<string, ThumbnailCacheEntry> | null = null;
function loadCache(): Record<string, ThumbnailCacheEntry> {
if (memoryCache) return memoryCache;
const raw = storage.getString(BLOB_KEY);
memoryCache = raw ? JSON.parse(raw) : {};
return memoryCache!;
}
function saveCache(cache: Record<string, ThumbnailCacheEntry>) {
memoryCache = cache;
storage.set(BLOB_KEY, JSON.stringify(cache));
}
export const thumbnailCache = {
get(fileId: string): string | null {
const cache = loadCache();
const entry = cache[fileId];
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
delete cache[fileId];
saveCache(cache);
return null;
}
return entry.url;
},
set(fileId: string, url: string, expiresAt: number) {
const cache = loadCache();
cache[fileId] = { url, expiresAt };
saveCache(cache);
},
setBatch(entries: Array<{ fileId: string; url: string; expiresAt: number }>) {
if (entries.length === 0) return;
const cache = loadCache();
for (const e of entries) {
cache[e.fileId] = { url: e.url, expiresAt: e.expiresAt };
}
saveCache(cache);
},
remove(fileId: string) {
const cache = loadCache();
delete cache[fileId];
saveCache(cache);
},
clear() {
memoryCache = {};
storage.remove(BLOB_KEY);
},
};