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 09:56:07 +00:00
committed by GitHub
parent 581bafbd37
commit e142af2863
32 changed files with 600 additions and 574 deletions
+7 -5
View File
@@ -3,13 +3,15 @@
* FileContext uses pure File objects with separate ID tracking
*/
declare const tag: unique symbol;
export type FileId = string & { readonly [tag]: 'FileId' };
/**
* File metadata for efficient operations without loading full file data
* Used by IndexedDBContext and FileContext for lazy file loading
*/
export interface FileMetadata {
id: string;
id: FileId;
name: string;
type: string;
size: number;
@@ -36,9 +38,9 @@ export const defaultStorageConfig: StorageConfig = {
export const initializeStorageConfig = async (): Promise<StorageConfig> => {
const tenGB = 10 * 1024 * 1024 * 1024; // 10GB in bytes
const oneGB = 1024 * 1024 * 1024; // 1GB fallback
let maxTotalStorage = oneGB; // Default fallback
// Try to estimate available storage
if ('storage' in navigator && 'estimate' in navigator.storage) {
try {
@@ -51,9 +53,9 @@ export const initializeStorageConfig = async (): Promise<StorageConfig> => {
console.warn('Could not estimate storage quota, using 1GB default:', error);
}
}
return {
...defaultStorageConfig,
maxTotalStorage
};
};
};
+26 -29
View File
@@ -2,9 +2,8 @@
* Types for global file context management across views and tools
*/
import { ProcessedFile } from './processing';
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
import { FileMetadata } from './file';
import { PageOperation } from './pageEditor';
import { FileId, FileMetadata } from './file';
export type ModeType =
| 'viewer'
@@ -26,8 +25,6 @@ export type ModeType =
| 'removeCertificateSign';
// Normalized state types
export type FileId = string;
export interface ProcessedFilePage {
thumbnail?: string;
pageNumber?: number;
@@ -69,14 +66,14 @@ export interface FileContextNormalizedFiles {
export function createFileId(): FileId {
// Use crypto.randomUUID for authoritative primary key
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
return window.crypto.randomUUID() as FileId;
}
// Fallback for environments without randomUUID
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}) as FileId;
}
// Generate quick deduplication key from file metadata
@@ -136,7 +133,7 @@ export interface FileOperation {
id: string;
type: OperationType;
timestamp: number;
fileIds: string[];
fileIds: FileId[];
status: 'pending' | 'applied' | 'failed';
data?: any;
metadata?: {
@@ -150,7 +147,7 @@ export interface FileOperation {
}
export interface FileOperationHistory {
fileId: string;
fileId: FileId;
fileName: string;
operations: (FileOperation | PageOperation)[];
createdAt: number;
@@ -165,7 +162,7 @@ export interface ViewerConfig {
}
export interface FileEditHistory {
fileId: string;
fileId: FileId;
pageOperations: PageOperation[];
lastModified: number;
}
@@ -176,10 +173,10 @@ export interface FileContextState {
ids: FileId[];
byId: Record<FileId, FileRecord>;
};
// Pinned files - files that won't be consumed by tools
// Pinned files - files that won't be consumed by tools
pinnedFiles: Set<FileId>;
// UI state - file-related UI state only
ui: {
selectedFileIds: FileId[];
@@ -191,27 +188,27 @@ export interface FileContextState {
}
// Action types for reducer pattern
export type FileContextAction =
export type FileContextAction =
// File management actions
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
// Pinned files actions
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
// UI actions
// UI actions
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
| { type: 'CLEAR_SELECTIONS' }
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
// Navigation guard actions (minimal for file-related unsaved changes only)
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
// Context management
| { type: 'RESET_CONTEXT' };
@@ -236,20 +233,20 @@ export interface FileContextActions {
setSelectedFiles: (fileIds: FileId[]) => void;
setSelectedPages: (pageNumbers: number[]) => void;
clearSelections: () => void;
// Processing state - simple flags only
setProcessing: (isProcessing: boolean, progress?: number) => void;
// File-related unsaved changes (minimal navigation guard support)
setHasUnsavedChanges: (hasChanges: boolean) => void;
// Context management
resetContext: () => void;
// Resource management
trackBlobUrl: (url: string) => void;
scheduleCleanup: (fileId: string, delay?: number) => void;
cleanupFile: (fileId: string) => void;
scheduleCleanup: (fileId: FileId, delay?: number) => void;
cleanupFile: (fileId: FileId) => void;
}
// File selectors (separate from actions to avoid re-renders)
@@ -257,22 +254,22 @@ export interface FileContextSelectors {
// File access - no state dependency, uses ref
getFile: (id: FileId) => File | undefined;
getFiles: (ids?: FileId[]) => File[];
// Record access - uses normalized state
getFileRecord: (id: FileId) => FileRecord | undefined;
getFileRecords: (ids?: FileId[]) => FileRecord[];
// Derived selectors
getAllFileIds: () => FileId[];
getSelectedFiles: () => File[];
getSelectedFileRecords: () => FileRecord[];
// Pinned files selectors
getPinnedFileIds: () => FileId[];
getPinnedFiles: () => File[];
getPinnedFileRecords: () => FileRecord[];
isFilePinned: (file: File) => boolean;
// Stable signature for effect dependencies
getFilesSignature: () => string;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { FileId } from './file';
export interface PDFPage {
id: string;
pageNumber: number;
@@ -7,7 +9,7 @@ export interface PDFPage {
selected: boolean;
splitAfter?: boolean;
isBlankPage?: boolean;
originalFileId?: string;
originalFileId?: FileId;
}
export interface PDFDocument {