Merge branch 'main' into SaaS

This commit is contained in:
Anthony Stirling
2026-06-10 11:42:25 +01:00
committed by GitHub
131 changed files with 17211 additions and 1451 deletions
@@ -0,0 +1,160 @@
/**
* Stores FileSystemDirectoryHandle instances per folder in IndexedDB.
* Handles are structured-cloneable so they survive page refresh.
* Permission must be re-requested each session before writing.
*/
const DB_NAME = "stirling-pdf-folder-directory-handles";
const DB_VERSION = 1;
const STORE = "handles";
/** Cached singleton DB connection — avoids opening a new connection per call. */
let cachedDB: IDBDatabase | null = null;
let initPromise: Promise<IDBDatabase> | null = null;
function getDB(): Promise<IDBDatabase> {
if (cachedDB) return Promise.resolve(cachedDB);
if (initPromise) return initPromise;
initPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => {
cachedDB = req.result;
cachedDB.onclose = () => {
cachedDB = null;
initPromise = null;
};
resolve(cachedDB);
};
req.onerror = () => {
initPromise = null;
reject(req.error);
};
});
return initPromise;
}
type ExtendedDirHandle = FileSystemDirectoryHandle & {
queryPermission(opts: object): Promise<PermissionState>;
requestPermission(opts: object): Promise<PermissionState>;
};
export const folderDirectoryHandleStorage = {
// ── Output directory handles (readwrite) ─────────────────────────────────
async get(folderId: string): Promise<FileSystemDirectoryHandle | null> {
const db = await getDB();
return new Promise((resolve, reject) => {
const req = db.transaction(STORE).objectStore(STORE).get(folderId);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
},
async set(
folderId: string,
handle: FileSystemDirectoryHandle,
): Promise<void> {
const db = await getDB();
return new Promise((resolve, reject) => {
const req = db
.transaction(STORE, "readwrite")
.objectStore(STORE)
.put(handle, folderId);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
async remove(folderId: string): Promise<void> {
const db = await getDB();
return new Promise((resolve, reject) => {
const req = db
.transaction(STORE, "readwrite")
.objectStore(STORE)
.delete(folderId);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
/**
* Verifies the handle still has readwrite permission, requesting it if needed.
* Returns true if permission is granted, false if denied/dismissed.
*/
async ensurePermission(handle: FileSystemDirectoryHandle): Promise<boolean> {
const opts = { mode: "readwrite" };
const h = handle as ExtendedDirHandle;
if ((await h.queryPermission(opts)) === "granted") return true;
return (await h.requestPermission(opts)) === "granted";
},
// ── Input directory handles (readonly) ────────────────────────────────────
// Stored under key "input:{folderId}" to avoid collisions with output handles.
async getInput(folderId: string): Promise<FileSystemDirectoryHandle | null> {
const db = await getDB();
return new Promise((resolve, reject) => {
const req = db
.transaction(STORE)
.objectStore(STORE)
.get(`input:${folderId}`);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
},
async setInput(
folderId: string,
handle: FileSystemDirectoryHandle,
): Promise<void> {
const db = await getDB();
return new Promise((resolve, reject) => {
const req = db
.transaction(STORE, "readwrite")
.objectStore(STORE)
.put(handle, `input:${folderId}`);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
async removeInput(folderId: string): Promise<void> {
const db = await getDB();
return new Promise((resolve, reject) => {
const req = db
.transaction(STORE, "readwrite")
.objectStore(STORE)
.delete(`input:${folderId}`);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
/**
* Verifies the handle still has read permission, requesting it if needed.
* On browsers that don't support queryPermission/requestPermission (Firefox),
* returns true optimistically — access errors will surface naturally during iteration.
*/
async ensureReadPermission(
handle: FileSystemDirectoryHandle,
): Promise<boolean> {
const h = handle as ExtendedDirHandle;
if (typeof h.queryPermission !== "function") return true; // Firefox — no permission API
const opts = { mode: "read" };
if ((await h.queryPermission(opts)) === "granted") return true;
return (await h.requestPermission(opts)) === "granted";
},
/** Write a file blob into the directory, overwriting if it exists. */
async writeFile(
handle: FileSystemDirectoryHandle,
name: string,
blob: Blob,
): Promise<void> {
const fileHandle = await handle.getFileHandle(name, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
},
};
@@ -0,0 +1,166 @@
/**
* Persistent retry schedule for Watched Folder automation.
*
* Stores pending retries in IndexedDB so they survive page close.
* The service worker reads this store to schedule timers; the main thread
* drains due entries on mount, on SW notification, and on visibilitychange.
*
* claimDue() is atomic (single readwrite IDB transaction) — safe across tabs.
*/
export interface RetryEntry {
/** `${folderId}:${fileId}` — natural composite key */
id: string;
folderId: string;
fileId: string;
/** Absolute ms timestamp when this retry should fire */
dueAt: number;
attempt: number;
ownedByFolder: boolean;
}
class FolderRetryScheduleStorage {
private dbName = "stirling-pdf-retry-schedule";
private dbVersion = 1;
private storeName = "retries";
private db: IDBDatabase | null = null;
private initPromise: Promise<void> | null = null;
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () =>
reject(new Error("Failed to open retry schedule database"));
request.onsuccess = () => {
this.db = request.result;
this.db.onclose = () => {
this.db = null;
this.initPromise = null;
};
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
const store = db.createObjectStore(this.storeName, { keyPath: "id" });
// Index on dueAt lets the SW and claimDue() range-scan efficiently
store.createIndex("dueAt", "dueAt", { unique: false });
}
};
});
}
private async ensureDB(): Promise<IDBDatabase> {
if (!this.db) {
this.initPromise ??= this.init();
await this.initPromise;
}
if (!this.db) throw new Error("Retry schedule database not initialized");
return this.db;
}
/** Upsert a retry entry. Calling again for the same file replaces the previous schedule. */
async schedule(
folderId: string,
fileId: string,
dueAt: number,
attempt: number,
ownedByFolder: boolean,
): Promise<void> {
const db = await this.ensureDB();
const entry: RetryEntry = {
id: `${folderId}:${fileId}`,
folderId,
fileId,
dueAt,
attempt,
ownedByFolder,
};
return new Promise((resolve, reject) => {
const tx = db.transaction([this.storeName], "readwrite");
const request = tx.objectStore(this.storeName).put(entry);
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error("Failed to schedule retry"));
});
}
/** Remove a scheduled retry (e.g. when a folder is deleted or a file is manually retried). */
async cancel(folderId: string, fileId: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([this.storeName], "readwrite");
const request = tx
.objectStore(this.storeName)
.delete(`${folderId}:${fileId}`);
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error("Failed to cancel retry"));
});
}
/**
* Atomically reads and deletes all entries whose dueAt is in the past.
* Because the delete happens inside the same readwrite transaction as the
* read, two concurrent tabs cannot both claim the same entry.
*/
async claimDue(): Promise<RetryEntry[]> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const now = Date.now();
const claimed: RetryEntry[] = [];
const tx = db.transaction([this.storeName], "readwrite");
const index = tx.objectStore(this.storeName).index("dueAt");
const cursorRequest = index.openCursor(IDBKeyRange.upperBound(now));
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (cursor) {
claimed.push(cursor.value as RetryEntry);
cursor.delete();
cursor.continue();
}
};
tx.oncomplete = () => resolve(claimed);
tx.onerror = () => reject(new Error("Failed to claim due retries"));
});
}
/** Remove all scheduled retries for a folder (called when the folder is deleted). */
async clearFolder(folderId: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([this.storeName], "readwrite");
const store = tx.objectStore(this.storeName);
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
const entry = cursor.value as RetryEntry;
if (entry.folderId === folderId) cursor.delete();
cursor.continue();
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new Error("Failed to clear folder retries"));
});
}
/** Returns the earliest scheduled dueAt timestamp, or null if no entries exist. */
async getEarliestDueAt(): Promise<number | null> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([this.storeName], "readonly");
const cursorRequest = tx
.objectStore(this.storeName)
.index("dueAt")
.openCursor();
cursorRequest.onsuccess = () =>
resolve(
cursorRequest.result
? (cursorRequest.result.value as RetryEntry).dueAt
: null,
);
cursorRequest.onerror = () =>
reject(new Error("Failed to get earliest due at"));
});
}
}
export const folderRetryScheduleStorage = new FolderRetryScheduleStorage();
@@ -0,0 +1,154 @@
/**
* Service for managing Watched Folder run state in IndexedDB
*/
import { WatchedFolderRunEntry } from "@app/types/watchedFolders";
const FOLDER_RUN_STATE_CHANGE_EVENT = "folder-run-state-changed";
interface RunStateRecord {
folderId: string;
runs: WatchedFolderRunEntry[];
lastUpdated: number;
}
class FolderRunStateStorage {
private dbName = "stirling-pdf-folder-run-state";
private dbVersion = 1;
private storeName = "runStates";
private db: IDBDatabase | null = null;
private initPromise: Promise<void> | null = null;
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () =>
reject(new Error("Failed to open folder run state database"));
request.onsuccess = () => {
this.db = request.result;
this.db.onclose = () => {
this.db = null;
this.initPromise = null;
};
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "folderId" });
}
};
});
}
private async ensureDB(): Promise<IDBDatabase> {
if (!this.db) {
this.initPromise ??= this.init();
await this.initPromise;
}
if (!this.db) {
throw new Error("Folder run state database not initialized");
}
return this.db;
}
async getFolderRunState(folderId: string): Promise<WatchedFolderRunEntry[]> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(folderId);
request.onsuccess = () => {
const record: RunStateRecord | undefined = request.result;
resolve(record?.runs || []);
};
request.onerror = () =>
reject(new Error("Failed to get folder run state"));
});
}
async setFolderRunState(
folderId: string,
runs: WatchedFolderRunEntry[],
): Promise<void> {
const db = await this.ensureDB();
const record: RunStateRecord = { folderId, runs, lastUpdated: Date.now() };
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.put(record);
request.onsuccess = () => {
window.dispatchEvent(
new CustomEvent(FOLDER_RUN_STATE_CHANGE_EVENT, {
detail: { folderId },
}),
);
resolve();
};
request.onerror = () =>
reject(new Error("Failed to set folder run state"));
});
}
onRunStateChange(listener: (folderId: string) => void): () => void {
const handler = (e: Event) => listener((e as CustomEvent).detail.folderId);
window.addEventListener(FOLDER_RUN_STATE_CHANGE_EVENT, handler);
return () =>
window.removeEventListener(FOLDER_RUN_STATE_CHANGE_EVENT, handler);
}
/** Atomically appends entries to a folder's run state within a single readwrite transaction,
* preventing lost-update races when multiple files are processed concurrently. */
async appendRunEntries(
folderId: string,
entries: WatchedFolderRunEntry[],
): Promise<void> {
if (entries.length === 0) return;
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const getRequest = store.get(folderId);
getRequest.onsuccess = () => {
const existing: RunStateRecord | undefined = getRequest.result;
const MAX_RUN_ENTRIES = 500;
const combined = [...(existing?.runs ?? []), ...entries];
const record: RunStateRecord = {
folderId,
runs:
combined.length > MAX_RUN_ENTRIES
? combined.slice(-MAX_RUN_ENTRIES)
: combined,
lastUpdated: Date.now(),
};
const putRequest = store.put(record);
putRequest.onsuccess = () => {
window.dispatchEvent(
new CustomEvent(FOLDER_RUN_STATE_CHANGE_EVENT, {
detail: { folderId },
}),
);
resolve();
};
putRequest.onerror = () =>
reject(new Error("Failed to append run entries"));
};
getRequest.onerror = () =>
reject(new Error("Failed to read run state for append"));
});
}
async clearFolderRunState(folderId: string): Promise<void> {
const db = await this.ensureDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.delete(folderId);
request.onsuccess = () => resolve();
request.onerror = () =>
reject(new Error("Failed to clear folder run state"));
});
}
}
export const folderRunStateStorage = new FolderRunStateStorage();
@@ -0,0 +1,84 @@
/**
* Tracks which local-folder input files have already been submitted for processing.
* Key: `{folderId}|{filename}|{size}|{lastModified}` — uniquely identifies a file version.
* Prevents re-submitting the same file on every poll cycle.
*/
const DB_NAME = "stirling-pdf-folder-seen-files";
const DB_VERSION = 1;
const STORE = "seenFiles";
/** Cached singleton DB connection — avoids opening a new connection per call. */
let cachedDB: IDBDatabase | null = null;
let initPromise: Promise<IDBDatabase> | null = null;
function getDB(): Promise<IDBDatabase> {
if (cachedDB) return Promise.resolve(cachedDB);
if (initPromise) return initPromise;
initPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => {
cachedDB = req.result;
cachedDB.onclose = () => {
cachedDB = null;
initPromise = null;
};
resolve(cachedDB);
};
req.onerror = () => {
initPromise = null;
reject(req.error);
};
});
return initPromise;
}
export function makeSeenKey(folderId: string, file: File): string {
return `${folderId}|${file.name}|${file.size}|${file.lastModified}`;
}
export const folderSeenFilesStorage = {
async isSeen(key: string): Promise<boolean> {
const db = await getDB();
return new Promise((resolve, reject) => {
const req = db.transaction(STORE).objectStore(STORE).get(key);
req.onsuccess = () => resolve(req.result != null);
req.onerror = () => reject(req.error);
});
},
async markSeen(key: string): Promise<void> {
const db = await getDB();
return new Promise((resolve, reject) => {
const req = db
.transaction(STORE, "readwrite")
.objectStore(STORE)
.put(Date.now(), key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
/** Remove all seen-file entries for a folder (called when folder is deleted or reset). */
async clearFolder(folderId: string): Promise<void> {
const db = await getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
const prefix = `${folderId}|`;
const range = IDBKeyRange.bound(prefix, prefix + "￿");
const req = store.openCursor(range);
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) {
resolve();
return;
}
cursor.delete();
cursor.continue();
};
req.onerror = () => reject(req.error);
});
},
};