# Description of Changes Currently, the `tsconfig.json` file enforces strict type checking, but nothing in CI checks that the code is actually correctly typed. [Vite only transpiles TypeScript code](https://vite.dev/guide/features.html#transpile-only) so doesn't ensure that the TS code we're running is correct. This PR adds running of the type checker to CI and fixes the type errors that have already crept into the codebase. Note that many of the changes I've made to 'fix the types' are just using `any` to disable the type checker because the code is under too much churn to fix anything properly at the moment. I still think enabling the type checker now is the best course of action though because otherwise we'll never be able to fix all of them, and it should at least help us not break things when adding new code. Co-authored-by: James <james@crosscourtanalytics.com>
605 lines
18 KiB
TypeScript
605 lines
18 KiB
TypeScript
/**
|
|
* IndexedDB File Storage Service
|
|
* Provides high-capacity file storage for PDF processing
|
|
*/
|
|
|
|
export interface StoredFile {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
size: number;
|
|
lastModified: number;
|
|
data: ArrayBuffer;
|
|
thumbnail?: string;
|
|
url?: string; // For compatibility with existing components
|
|
}
|
|
|
|
export interface StorageStats {
|
|
used: number;
|
|
available: number;
|
|
fileCount: number;
|
|
quota?: number;
|
|
}
|
|
|
|
class FileStorageService {
|
|
private dbName = 'stirling-pdf-files';
|
|
private dbVersion = 2; // Increment version to force schema update
|
|
private storeName = 'files';
|
|
private db: IDBDatabase | null = null;
|
|
private initPromise: Promise<void> | null = null;
|
|
|
|
/**
|
|
* Initialize the IndexedDB database (singleton pattern)
|
|
*/
|
|
async init(): Promise<void> {
|
|
if (this.db) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
if (this.initPromise) {
|
|
return this.initPromise;
|
|
}
|
|
|
|
this.initPromise = new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
|
|
request.onerror = () => {
|
|
this.initPromise = null;
|
|
reject(request.error);
|
|
};
|
|
|
|
request.onsuccess = () => {
|
|
this.db = request.result;
|
|
console.log('IndexedDB connection established');
|
|
resolve();
|
|
};
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
const oldVersion = (event as any).oldVersion;
|
|
|
|
console.log('IndexedDB upgrade needed from version', oldVersion, 'to', this.dbVersion);
|
|
|
|
// Only recreate object store if it doesn't exist or if upgrading from version < 2
|
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
|
store.createIndex('name', 'name', { unique: false });
|
|
store.createIndex('lastModified', 'lastModified', { unique: false });
|
|
console.log('IndexedDB object store created with keyPath: id');
|
|
} else if (oldVersion < 2) {
|
|
// Only delete and recreate if upgrading from version 1 to 2
|
|
db.deleteObjectStore(this.storeName);
|
|
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
|
store.createIndex('name', 'name', { unique: false });
|
|
store.createIndex('lastModified', 'lastModified', { unique: false });
|
|
console.log('IndexedDB object store recreated with keyPath: id (version upgrade)');
|
|
}
|
|
};
|
|
});
|
|
|
|
return this.initPromise;
|
|
}
|
|
|
|
/**
|
|
* Store a file in IndexedDB
|
|
*/
|
|
async storeFile(file: File, thumbnail?: string): Promise<StoredFile> {
|
|
if (!this.db) await this.init();
|
|
|
|
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
|
|
const storedFile: StoredFile = {
|
|
id,
|
|
name: file.name,
|
|
type: file.type,
|
|
size: file.size,
|
|
lastModified: file.lastModified,
|
|
data: arrayBuffer,
|
|
thumbnail
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
// Debug logging
|
|
console.log('Object store keyPath:', store.keyPath);
|
|
console.log('Storing file:', {
|
|
id: storedFile.id,
|
|
name: storedFile.name,
|
|
hasData: !!storedFile.data,
|
|
dataSize: storedFile.data.byteLength
|
|
});
|
|
|
|
const request = store.add(storedFile);
|
|
|
|
request.onerror = () => {
|
|
console.error('IndexedDB add error:', request.error);
|
|
console.error('Failed object:', storedFile);
|
|
reject(request.error);
|
|
};
|
|
request.onsuccess = () => {
|
|
console.log('File stored successfully with ID:', storedFile.id);
|
|
resolve(storedFile);
|
|
};
|
|
} catch (error) {
|
|
console.error('Transaction error:', error);
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retrieve a file from IndexedDB
|
|
*/
|
|
async getFile(id: string): Promise<StoredFile | null> {
|
|
if (!this.db) await this.init();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.get(id);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all stored files (WARNING: loads all data into memory)
|
|
*/
|
|
async getAllFiles(): Promise<StoredFile[]> {
|
|
if (!this.db) await this.init();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.getAll();
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
// Filter out null/corrupted entries
|
|
const files = request.result.filter(file =>
|
|
file &&
|
|
file.data &&
|
|
file.name &&
|
|
typeof file.size === 'number'
|
|
);
|
|
resolve(files);
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get metadata of all stored files (without loading data into memory)
|
|
*/
|
|
async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> {
|
|
if (!this.db) await this.init();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.openCursor();
|
|
const files: Omit<StoredFile, 'data'>[] = [];
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = (event) => {
|
|
const cursor = (event.target as IDBRequest).result;
|
|
if (cursor) {
|
|
const storedFile = cursor.value;
|
|
// Only extract metadata, skip the data field
|
|
if (storedFile && storedFile.name && typeof storedFile.size === 'number') {
|
|
files.push({
|
|
id: storedFile.id,
|
|
name: storedFile.name,
|
|
type: storedFile.type,
|
|
size: storedFile.size,
|
|
lastModified: storedFile.lastModified,
|
|
thumbnail: storedFile.thumbnail
|
|
});
|
|
}
|
|
cursor.continue();
|
|
} else {
|
|
console.log('Loaded metadata for', files.length, 'files without loading data');
|
|
resolve(files);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a file from IndexedDB
|
|
*/
|
|
async deleteFile(id: string): Promise<void> {
|
|
if (!this.db) await this.init();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.delete(id);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update the lastModified timestamp of a file (for most recently used sorting)
|
|
*/
|
|
async touchFile(id: string): Promise<boolean> {
|
|
if (!this.db) await this.init();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
const getRequest = store.get(id);
|
|
getRequest.onsuccess = () => {
|
|
const file = getRequest.result;
|
|
if (file) {
|
|
// Update lastModified to current timestamp
|
|
file.lastModified = Date.now();
|
|
const updateRequest = store.put(file);
|
|
updateRequest.onsuccess = () => resolve(true);
|
|
updateRequest.onerror = () => reject(updateRequest.error);
|
|
} else {
|
|
resolve(false); // File not found
|
|
}
|
|
};
|
|
getRequest.onerror = () => reject(getRequest.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear all stored files
|
|
*/
|
|
async clearAll(): Promise<void> {
|
|
if (!this.db) await this.init();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.clear();
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get storage statistics (only our IndexedDB usage)
|
|
*/
|
|
async getStorageStats(): Promise<StorageStats> {
|
|
if (!this.db) await this.init();
|
|
|
|
let used = 0;
|
|
let available = 0;
|
|
let quota: number | undefined;
|
|
let fileCount = 0;
|
|
|
|
try {
|
|
// Get browser quota for context
|
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
|
const estimate = await navigator.storage.estimate();
|
|
quota = estimate.quota;
|
|
available = estimate.quota || 0;
|
|
}
|
|
|
|
// Calculate our actual IndexedDB usage from file metadata
|
|
const files = await this.getAllFileMetadata();
|
|
used = files.reduce((total, file) => total + (file?.size || 0), 0);
|
|
fileCount = files.length;
|
|
|
|
// Adjust available space
|
|
if (quota) {
|
|
available = quota - used;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.warn('Could not get storage stats:', error);
|
|
// If we can't read metadata, database might be purged
|
|
used = 0;
|
|
fileCount = 0;
|
|
}
|
|
|
|
return {
|
|
used,
|
|
available,
|
|
fileCount,
|
|
quota
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get file count quickly without loading metadata
|
|
*/
|
|
async getFileCount(): Promise<number> {
|
|
if (!this.db) await this.init();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const request = store.count();
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check all IndexedDB databases to see if files are in another version
|
|
*/
|
|
async debugAllDatabases(): Promise<void> {
|
|
console.log('=== Checking All IndexedDB Databases ===');
|
|
|
|
if ('databases' in indexedDB) {
|
|
try {
|
|
const databases = await indexedDB.databases();
|
|
console.log('Found databases:', databases);
|
|
|
|
for (const dbInfo of databases) {
|
|
if (dbInfo.name?.includes('stirling') || dbInfo.name?.includes('pdf')) {
|
|
console.log(`Checking database: ${dbInfo.name} (version: ${dbInfo.version})`);
|
|
try {
|
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open(dbInfo.name!, dbInfo.version);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
|
|
console.log(`Database ${dbInfo.name} object stores:`, Array.from(db.objectStoreNames));
|
|
db.close();
|
|
} catch (error) {
|
|
console.error(`Failed to open database ${dbInfo.name}:`, error);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to list databases:', error);
|
|
}
|
|
} else {
|
|
console.log('indexedDB.databases() not supported');
|
|
}
|
|
|
|
// Also check our specific database with different versions
|
|
for (let version = 1; version <= 3; version++) {
|
|
try {
|
|
console.log(`Trying to open ${this.dbName} version ${version}...`);
|
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open(this.dbName, version);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
request.onupgradeneeded = () => {
|
|
// Don't actually upgrade, just check
|
|
request.transaction?.abort();
|
|
};
|
|
});
|
|
|
|
console.log(`Version ${version} object stores:`, Array.from(db.objectStoreNames));
|
|
|
|
if (db.objectStoreNames.contains('files')) {
|
|
const transaction = db.transaction(['files'], 'readonly');
|
|
const store = transaction.objectStore('files');
|
|
const countRequest = store.count();
|
|
countRequest.onsuccess = () => {
|
|
console.log(`Version ${version} files store has ${countRequest.result} entries`);
|
|
};
|
|
}
|
|
|
|
db.close();
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.log(`Version ${version} not accessible:`, error.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Debug method to check what's actually in the database
|
|
*/
|
|
async debugDatabaseContents(): Promise<void> {
|
|
if (!this.db) await this.init();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
|
const store = transaction.objectStore(this.storeName);
|
|
|
|
// First try getAll to see if there's anything
|
|
const getAllRequest = store.getAll();
|
|
getAllRequest.onsuccess = () => {
|
|
console.log('=== Raw getAll() result ===');
|
|
console.log('Raw entries found:', getAllRequest.result.length);
|
|
getAllRequest.result.forEach((item, index) => {
|
|
console.log(`Raw entry ${index}:`, {
|
|
keys: Object.keys(item || {}),
|
|
id: item?.id,
|
|
name: item?.name,
|
|
size: item?.size,
|
|
type: item?.type,
|
|
hasData: !!item?.data,
|
|
dataSize: item?.data?.byteLength,
|
|
fullObject: item
|
|
});
|
|
});
|
|
};
|
|
|
|
// Then try cursor
|
|
const cursorRequest = store.openCursor();
|
|
console.log('=== IndexedDB Cursor Debug ===');
|
|
let count = 0;
|
|
|
|
cursorRequest.onerror = () => {
|
|
console.error('Cursor error:', cursorRequest.error);
|
|
reject(cursorRequest.error);
|
|
};
|
|
|
|
cursorRequest.onsuccess = (event) => {
|
|
const cursor = (event.target as IDBRequest).result;
|
|
if (cursor) {
|
|
count++;
|
|
const value = cursor.value;
|
|
console.log(`Cursor File ${count}:`, {
|
|
id: value?.id,
|
|
name: value?.name,
|
|
size: value?.size,
|
|
type: value?.type,
|
|
hasData: !!value?.data,
|
|
dataSize: value?.data?.byteLength,
|
|
hasThumbnail: !!value?.thumbnail,
|
|
allKeys: Object.keys(value || {})
|
|
});
|
|
cursor.continue();
|
|
} else {
|
|
console.log(`=== End Cursor Debug - Found ${count} files ===`);
|
|
resolve();
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convert StoredFile back to File object for compatibility
|
|
*/
|
|
createFileFromStored(storedFile: StoredFile): File {
|
|
if (!storedFile || !storedFile.data) {
|
|
throw new Error('Invalid stored file: missing data');
|
|
}
|
|
|
|
if (!storedFile.name || typeof storedFile.size !== 'number') {
|
|
throw new Error('Invalid stored file: missing metadata');
|
|
}
|
|
|
|
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
|
const file = new File([blob], storedFile.name, {
|
|
type: storedFile.type,
|
|
lastModified: storedFile.lastModified
|
|
});
|
|
|
|
// Add custom properties for compatibility
|
|
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
|
Object.defineProperty(file, 'thumbnail', { value: storedFile.thumbnail, writable: false });
|
|
|
|
return file;
|
|
}
|
|
|
|
/**
|
|
* Create blob URL for stored file
|
|
*/
|
|
createBlobUrl(storedFile: StoredFile): string {
|
|
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
/**
|
|
* Get file data as ArrayBuffer for streaming/chunked processing
|
|
*/
|
|
async getFileData(id: string): Promise<ArrayBuffer | null> {
|
|
try {
|
|
const storedFile = await this.getFile(id);
|
|
return storedFile ? storedFile.data : null;
|
|
} catch (error) {
|
|
console.warn(`Failed to get file data for ${id}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a temporary blob URL that gets revoked automatically
|
|
*/
|
|
async createTemporaryBlobUrl(id: string): Promise<string | null> {
|
|
const data = await this.getFileData(id);
|
|
if (!data) return null;
|
|
|
|
const blob = new Blob([data], { type: 'application/pdf' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// Auto-revoke after a short delay to free memory
|
|
setTimeout(() => {
|
|
URL.revokeObjectURL(url);
|
|
}, 10000); // 10 seconds
|
|
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Update thumbnail for an existing file
|
|
*/
|
|
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> {
|
|
if (!this.db) await this.init();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
|
const store = transaction.objectStore(this.storeName);
|
|
const getRequest = store.get(id);
|
|
|
|
getRequest.onsuccess = () => {
|
|
const storedFile = getRequest.result;
|
|
if (storedFile) {
|
|
storedFile.thumbnail = thumbnail;
|
|
const updateRequest = store.put(storedFile);
|
|
|
|
updateRequest.onsuccess = () => {
|
|
console.log('Thumbnail updated for file:', id);
|
|
resolve(true);
|
|
};
|
|
updateRequest.onerror = () => {
|
|
console.error('Failed to update thumbnail:', updateRequest.error);
|
|
resolve(false);
|
|
};
|
|
} else {
|
|
resolve(false);
|
|
}
|
|
};
|
|
|
|
getRequest.onerror = () => {
|
|
console.error('Failed to get file for thumbnail update:', getRequest.error);
|
|
resolve(false);
|
|
};
|
|
} catch (error) {
|
|
console.error('Transaction error during thumbnail update:', error);
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if storage quota is running low
|
|
*/
|
|
async isStorageLow(): Promise<boolean> {
|
|
const stats = await this.getStorageStats();
|
|
if (!stats.quota) return false;
|
|
|
|
const usagePercent = stats.used / stats.quota;
|
|
return usagePercent > 0.8; // Consider low if over 80% used
|
|
}
|
|
|
|
/**
|
|
* Clean up old files if storage is low
|
|
*/
|
|
async cleanupOldFiles(maxFiles: number = 50): Promise<void> {
|
|
const files = await this.getAllFileMetadata();
|
|
|
|
if (files.length <= maxFiles) return;
|
|
|
|
// Sort by last modified (oldest first)
|
|
files.sort((a, b) => a.lastModified - b.lastModified);
|
|
|
|
// Delete oldest files
|
|
const filesToDelete = files.slice(0, files.length - maxFiles);
|
|
for (const file of filesToDelete) {
|
|
await this.deleteFile(file.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const fileStorage = new FileStorageService();
|
|
|
|
// Helper hook for React components
|
|
export function useFileStorage() {
|
|
return fileStorage;
|
|
}
|