mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 18:44:05 +02:00
e2536daeb8
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
167 lines
5.7 KiB
TypeScript
167 lines
5.7 KiB
TypeScript
/**
|
|
* 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();
|