V2 Make FileId type opaque and use consistently throughout project (#4307)

# Description of Changes
The `FileId` type in V2 currently is just defined to be a string. This
makes it really easy to accidentally pass strings into things accepting
file IDs (such as file names). This PR makes the `FileId` type [an
opaque
type](https://www.geeksforgeeks.org/typescript/opaque-types-in-typescript/),
so it is compatible with things accepting strings (arguably not ideal
for this...) but strings are not compatible with it without explicit
conversion.

The PR also includes changes to use `FileId` consistently throughout the
project (everywhere I could find uses of `fileId: string`), so that we
have the maximum benefit from the type safety.

> [!note]
> I've marked quite a few things as `FIX ME` where we're passing names
in as IDs. If that is intended behaviour, I'm happy to remove the fix me
and insert a cast instead, but they probably need comments explaining
why we're using a file name as an ID.
This commit is contained in:
James Brunton
2025-08-28 10:56:07 +01:00
committed by GitHub
parent 581bafbd37
commit e142af2863
32 changed files with 600 additions and 574 deletions

View File

@@ -7,6 +7,7 @@
import * as pdfjsLib from 'pdfjs-dist';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { pdfWorkerManager } from './pdfWorkerManager';
import { FileId } from '../types/file';
export interface ProcessedFileMetadata {
totalPages: number;
@@ -38,7 +39,7 @@ class FileProcessingService {
* Process a file to extract metadata, page count, and generate thumbnails
* This is the single source of truth for file processing
*/
async processFile(file: File, fileId: string): Promise<FileProcessingResult> {
async processFile(file: File, fileId: FileId): Promise<FileProcessingResult> {
// Check if we're already processing this file
const existingOperation = this.processingCache.get(fileId);
if (existingOperation) {
@@ -48,10 +49,10 @@ class FileProcessingService {
// Create abort controller for this operation
const abortController = new AbortController();
// Create processing promise
const processingPromise = this.performProcessing(file, fileId, abortController);
// Store operation with abort controller
const operation: ProcessingOperation = {
promise: processingPromise,
@@ -67,7 +68,7 @@ class FileProcessingService {
return processingPromise;
}
private async performProcessing(file: File, fileId: string, abortController: AbortController): Promise<FileProcessingResult> {
private async performProcessing(file: File, fileId: FileId, abortController: AbortController): Promise<FileProcessingResult> {
console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`);
try {
@@ -83,12 +84,12 @@ class FileProcessingService {
if (file.type === 'application/pdf') {
// Read arrayBuffer once and reuse for both PDF.js and fallback
const arrayBuffer = await file.arrayBuffer();
// Check for cancellation after async operation
if (abortController.signal.aborted) {
throw new Error('Processing cancelled');
}
// Discover page count using PDF.js (most accurate)
try {
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
@@ -101,7 +102,7 @@ class FileProcessingService {
// Clean up immediately
pdfWorkerManager.destroyDocument(pdfDoc);
// Check for cancellation after PDF.js processing
if (abortController.signal.aborted) {
throw new Error('Processing cancelled');
@@ -116,7 +117,7 @@ class FileProcessingService {
try {
thumbnailUrl = await generateThumbnailForFile(file);
console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`);
// Check for cancellation after thumbnail generation
if (abortController.signal.aborted) {
throw new Error('Processing cancelled');
@@ -141,7 +142,7 @@ class FileProcessingService {
};
console.log(`📁 FileProcessingService: Processing complete for ${file.name} - ${totalPages} pages`);
return {
success: true,
metadata
@@ -149,7 +150,7 @@ class FileProcessingService {
} catch (error) {
console.error(`📁 FileProcessingService: Processing failed for ${file.name}:`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown processing error'
@@ -167,14 +168,14 @@ class FileProcessingService {
/**
* Check if a file is currently being processed
*/
isProcessing(fileId: string): boolean {
isProcessing(fileId: FileId): boolean {
return this.processingCache.has(fileId);
}
/**
* Cancel processing for a specific file
*/
cancelProcessing(fileId: string): boolean {
cancelProcessing(fileId: FileId): boolean {
const operation = this.processingCache.get(fileId);
if (operation) {
operation.abortController.abort();
@@ -206,4 +207,4 @@ class FileProcessingService {
}
// Export singleton instance
export const fileProcessingService = new FileProcessingService();
export const fileProcessingService = new FileProcessingService();

View File

@@ -4,10 +4,11 @@
* Now uses centralized IndexedDB manager
*/
import { FileId } from '../types/file';
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface StoredFile {
id: string;
id: FileId;
name: string;
type: string;
size: number;
@@ -38,7 +39,7 @@ class FileStorageService {
/**
* Store a file in IndexedDB with external UUID
*/
async storeFile(file: File, fileId: string, thumbnail?: string): Promise<StoredFile> {
async storeFile(file: File, fileId: FileId, thumbnail?: string): Promise<StoredFile> {
const db = await this.getDatabase();
const arrayBuffer = await file.arrayBuffer();
@@ -88,7 +89,7 @@ class FileStorageService {
/**
* Retrieve a file from IndexedDB
*/
async getFile(id: string): Promise<StoredFile | null> {
async getFile(id: FileId): Promise<StoredFile | null> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
@@ -166,7 +167,7 @@ class FileStorageService {
/**
* Delete a file from IndexedDB
*/
async deleteFile(id: string): Promise<void> {
async deleteFile(id: FileId): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
@@ -182,12 +183,12 @@ class FileStorageService {
/**
* Update the lastModified timestamp of a file (for most recently used sorting)
*/
async touchFile(id: string): Promise<boolean> {
async touchFile(id: FileId): Promise<boolean> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const file = getRequest.result;
@@ -438,9 +439,9 @@ class FileStorageService {
* Convert StoredFile to the format expected by FileContext.addStoredFiles()
* This is the recommended way to load stored files into FileContext
*/
createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: string; metadata: { thumbnail?: string } } {
createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: FileId; metadata: { thumbnail?: string } } {
const file = this.createFileFromStored(storedFile);
return {
file,
originalId: storedFile.id,
@@ -461,7 +462,7 @@ class FileStorageService {
/**
* Get file data as ArrayBuffer for streaming/chunked processing
*/
async getFileData(id: string): Promise<ArrayBuffer | null> {
async getFileData(id: FileId): Promise<ArrayBuffer | null> {
try {
const storedFile = await this.getFile(id);
return storedFile ? storedFile.data : null;
@@ -474,7 +475,7 @@ class FileStorageService {
/**
* Create a temporary blob URL that gets revoked automatically
*/
async createTemporaryBlobUrl(id: string): Promise<string | null> {
async createTemporaryBlobUrl(id: FileId): Promise<string | null> {
const data = await this.getFileData(id);
if (!data) return null;
@@ -492,7 +493,7 @@ class FileStorageService {
/**
* Update thumbnail for an existing file
*/
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> {
async updateThumbnail(id: FileId, thumbnail: string): Promise<boolean> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {

View File

@@ -2,6 +2,7 @@
* High-performance thumbnail generation service using main thread processing
*/
import { FileId } from '../types/file';
import { pdfWorkerManager } from './pdfWorkerManager';
interface ThumbnailResult {
@@ -32,12 +33,12 @@ interface CachedPDFDocument {
export class ThumbnailGenerationService {
// Session-based thumbnail cache
private thumbnailCache = new Map<string, CachedThumbnail>();
private thumbnailCache = new Map<FileId | string /* FIX ME: Page ID */, CachedThumbnail>();
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
private currentCacheSize = 0;
// PDF document cache to reuse PDF instances and avoid creating multiple workers
private pdfDocumentCache = new Map<string, CachedPDFDocument>();
private pdfDocumentCache = new Map<FileId, CachedPDFDocument>();
private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached
constructor(private maxWorkers: number = 10) {
@@ -47,7 +48,7 @@ export class ThumbnailGenerationService {
/**
* Get or create a cached PDF document
*/
private async getCachedPDFDocument(fileId: string, pdfArrayBuffer: ArrayBuffer): Promise<any> {
private async getCachedPDFDocument(fileId: FileId, pdfArrayBuffer: ArrayBuffer): Promise<any> {
const cached = this.pdfDocumentCache.get(fileId);
if (cached) {
cached.lastUsed = Date.now();
@@ -79,7 +80,7 @@ export class ThumbnailGenerationService {
/**
* Release a reference to a cached PDF document
*/
private releasePDFDocument(fileId: string): void {
private releasePDFDocument(fileId: FileId): void {
const cached = this.pdfDocumentCache.get(fileId);
if (cached) {
cached.refCount--;
@@ -91,7 +92,7 @@ export class ThumbnailGenerationService {
* Evict the least recently used PDF document
*/
private evictLeastRecentlyUsedPDF(): void {
let oldestEntry: [string, CachedPDFDocument] | null = null;
let oldestEntry: [FileId, CachedPDFDocument] | null = null;
let oldestTime = Date.now();
for (const [key, value] of this.pdfDocumentCache.entries()) {
@@ -111,7 +112,7 @@ export class ThumbnailGenerationService {
* Generate thumbnails for multiple pages using main thread processing
*/
async generateThumbnails(
fileId: string,
fileId: FileId,
pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[],
options: ThumbnailGenerationOptions = {},
@@ -121,11 +122,11 @@ export class ThumbnailGenerationService {
if (!fileId || typeof fileId !== 'string' || fileId.trim() === '') {
throw new Error('generateThumbnails: fileId must be a non-empty string');
}
if (!pdfArrayBuffer || pdfArrayBuffer.byteLength === 0) {
throw new Error('generateThumbnails: pdfArrayBuffer must not be empty');
}
if (!pageNumbers || pageNumbers.length === 0) {
throw new Error('generateThumbnails: pageNumbers must not be empty');
}
@@ -142,7 +143,7 @@ export class ThumbnailGenerationService {
* Main thread thumbnail generation with batching for UI responsiveness
*/
private async generateThumbnailsMainThread(
fileId: string,
fileId: FileId,
pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[],
scale: number,
@@ -150,48 +151,48 @@ export class ThumbnailGenerationService {
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> {
const pdf = await this.getCachedPDFDocument(fileId, pdfArrayBuffer);
const allResults: ThumbnailResult[] = [];
let completed = 0;
const batchSize = 3; // Smaller batches for better UI responsiveness
// Process pages in small batches
for (let i = 0; i < pageNumbers.length; i += batchSize) {
const batch = pageNumbers.slice(i, i + batchSize);
// Process batch sequentially (to avoid canvas conflicts)
for (const pageNumber of batch) {
try {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL('image/jpeg', quality);
allResults.push({ pageNumber, thumbnail, success: true });
} catch (error) {
console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error);
allResults.push({
pageNumber,
thumbnail: '',
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
allResults.push({
pageNumber,
thumbnail: '',
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
completed += batch.length;
// Report progress
if (onProgress) {
onProgress({
@@ -200,16 +201,16 @@ export class ThumbnailGenerationService {
thumbnails: allResults.slice(-batch.length).filter(r => r.success)
});
}
// Yield control to prevent UI blocking
await new Promise(resolve => setTimeout(resolve, 1));
}
// Release reference to PDF document (don't destroy - keep in cache)
this.releasePDFDocument(fileId);
this.cleanupCompletedDocument(fileId);
return allResults;
}
@@ -227,7 +228,7 @@ export class ThumbnailGenerationService {
addThumbnailToCache(pageId: string, thumbnail: string): void {
const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string
// Enforce cache size limits
while (this.currentCacheSize + sizeBytes > this.maxCacheSizeBytes && this.thumbnailCache.size > 0) {
this.evictLeastRecentlyUsed();
@@ -238,7 +239,7 @@ export class ThumbnailGenerationService {
lastUsed: Date.now(),
sizeBytes
});
this.currentCacheSize += sizeBytes;
}
@@ -284,7 +285,7 @@ export class ThumbnailGenerationService {
this.pdfDocumentCache.clear();
}
clearPDFCacheForFile(fileId: string): void {
clearPDFCacheForFile(fileId: FileId): void {
const cached = this.pdfDocumentCache.get(fileId);
if (cached) {
pdfWorkerManager.destroyDocument(cached.pdf);
@@ -296,7 +297,7 @@ export class ThumbnailGenerationService {
* Clean up a PDF document from cache when thumbnail generation is complete
* This frees up workers faster for better performance
*/
cleanupCompletedDocument(fileId: string): void {
cleanupCompletedDocument(fileId: FileId): void {
const cached = this.pdfDocumentCache.get(fileId);
if (cached && cached.refCount <= 0) {
pdfWorkerManager.destroyDocument(cached.pdf);
@@ -311,4 +312,4 @@ export class ThumbnailGenerationService {
}
// Global singleton instance
export const thumbnailGenerationService = new ThumbnailGenerationService();
export const thumbnailGenerationService = new ThumbnailGenerationService();