mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Update indexeddb to v9 to unify SaaS and OSS users (#6474)
# Description of Changes The production SaaS is currently on v8 of IndexedDB due to various schema changes for Smart Folders, which haven't made their way into OSS. OSS is currently on v4 of IndexedDB, so if we release an OSS build to the SaaS deployment, existing users will not be able to use it because the DB version is 'too old'. This PR updates the IDB version number to v9 so both OSS and SaaS users will be able to upgrade to it. Theoretically both types of user should be able to keep their IDB files without issue. SaaS previously actively wiped the user's files in an old version (v6/v7) and users who haven't used it since then will have their DBs wiped, but that'd happen anyway if they use current SaaS so I don't think that matters.
This commit is contained in:
@@ -15,10 +15,17 @@ import {
|
||||
* back its pre-v3 snapshot and silently erased every v3 field on every
|
||||
* row (isLeaf, versionNumber, originalFileId, parentFileId, toolHistory).
|
||||
*
|
||||
* A future v5 migration written as a third separate cursor walk would
|
||||
* A future v10 migration written as a third separate cursor walk would
|
||||
* re-introduce the exact same failure mode for anyone jumping multiple
|
||||
* versions. This test pins the behaviour by seeding a v2 DB and asserting
|
||||
* every v3+v4 field is present and correctly set after the upgrade.
|
||||
*
|
||||
* Also covers the SaaS-lineage reconciliation: SaaS shipped its own
|
||||
* versions of this database up to v8 (v5 added folder_* stores, v8 was
|
||||
* the terminal SaaS schema). When the unified codebase first opens a
|
||||
* SaaS browser's database it has to drop those orphan stores, backfill
|
||||
* folderId on every file row, and (for v6/v7 specifically) force-delete
|
||||
* the database because its data is known-corrupt.
|
||||
*/
|
||||
|
||||
const DB_NAME = DATABASE_CONFIGS.FILES.name;
|
||||
@@ -110,6 +117,90 @@ function seedV3Database(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a SaaS-shaped database at the given version. Mirrors the SaaS
|
||||
* terminal schema: `files` store plus the three folder_* / smart_folders
|
||||
* stores that we now treat as orphans. SaaS file rows are v3-shaped
|
||||
* (they got the file history fields via the SaaS migrateFileHistoryFields
|
||||
* path) but have never had a folderId.
|
||||
*/
|
||||
function seedSaasDatabase(version: number, fileIds: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, version);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
db.createObjectStore("files", { keyPath: "id" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains("folder_members")) {
|
||||
db.createObjectStore("folder_members", { keyPath: "folderId" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains("folder_run_states")) {
|
||||
db.createObjectStore("folder_run_states", { keyPath: "folderId" });
|
||||
}
|
||||
if (!db.objectStoreNames.contains("smart_folders")) {
|
||||
db.createObjectStore("smart_folders", { keyPath: "folderId" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction(
|
||||
["files", "folder_members", "smart_folders"],
|
||||
"readwrite",
|
||||
);
|
||||
const filesStore = tx.objectStore("files");
|
||||
for (const id of fileIds) {
|
||||
filesStore.add({
|
||||
id,
|
||||
name: `${id}.pdf`,
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: 5000,
|
||||
data: new Blob([id], { type: "application/pdf" }),
|
||||
// v3 fields - SaaS records always have these by v3+
|
||||
isLeaf: true,
|
||||
versionNumber: 1,
|
||||
originalFileId: id,
|
||||
parentFileId: undefined,
|
||||
toolHistory: [],
|
||||
// intentionally no folderId field - SaaS lineage never had it
|
||||
});
|
||||
}
|
||||
// Drop a SaaS-only row in folder_members and smart_folders so we
|
||||
// can verify the orphan stores were actually dropped (not just
|
||||
// empty).
|
||||
tx.objectStore("folder_members").add({
|
||||
folderId: "saas-folder-1",
|
||||
fileIds: [...fileIds],
|
||||
});
|
||||
tx.objectStore("smart_folders").add({
|
||||
folderId: "saas-folder-1",
|
||||
files: {},
|
||||
lastUpdated: 6000,
|
||||
});
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error ?? new Error("SaaS seed tx failed"));
|
||||
};
|
||||
req.onerror = () => reject(req.error ?? new Error("SaaS seed open failed"));
|
||||
});
|
||||
}
|
||||
|
||||
function getObjectStoreNames(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const names = Array.from(db.objectStoreNames);
|
||||
db.close();
|
||||
resolve(names);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function readAllFiles(): Promise<unknown[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME);
|
||||
@@ -193,4 +284,112 @@ describe("IndexedDB migration (FILES store)", () => {
|
||||
);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
});
|
||||
|
||||
test("SaaS v8 -> latest backfills folderId, preserves files, drops orphan stores", async () => {
|
||||
await seedSaasDatabase(8, ["saas-file-a", "saas-file-b"]);
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
|
||||
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
// SaaS file rows already had v3 fields - migration should leave them alone.
|
||||
expect(row.isLeaf).toBe(true);
|
||||
expect(row.versionNumber).toBe(1);
|
||||
expect(row.originalFileId).toBe(row.id);
|
||||
expect(row.toolHistory).toEqual([]);
|
||||
// Critical: SaaS lineage never had folderId, the new schema requires it.
|
||||
expect(row.folderId).toBeNull();
|
||||
}
|
||||
|
||||
// Orphan SaaS-only stores should be gone; the v9 schema's `folders`
|
||||
// store should exist; the `files` store survives.
|
||||
const stores = await getObjectStoreNames();
|
||||
expect(stores).toContain("files");
|
||||
expect(stores).toContain("folders");
|
||||
expect(stores).not.toContain("folder_members");
|
||||
expect(stores).not.toContain("folder_run_states");
|
||||
expect(stores).not.toContain("smart_folders");
|
||||
|
||||
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
|
||||
TARGET_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
test("SaaS v5 (pre-orphan-stores edge case) backfills folderId", async () => {
|
||||
// v5 predates folder_members / folder_run_states / smart_folders in
|
||||
// SaaS lineage, so seed it with only the files store. SaaS v5 file
|
||||
// rows still lack folderId; this verifies the field-presence check
|
||||
// doesn't depend on the orphan stores existing.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 5);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
db.createObjectStore("files", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
tx.objectStore("files").add({
|
||||
id: "saas-v5-file",
|
||||
name: "saas-v5.pdf",
|
||||
type: "application/pdf",
|
||||
size: 256,
|
||||
lastModified: 7000,
|
||||
data: new Blob(["v5"], { type: "application/pdf" }),
|
||||
isLeaf: true,
|
||||
versionNumber: 1,
|
||||
originalFileId: "saas-v5-file",
|
||||
parentFileId: undefined,
|
||||
toolHistory: [],
|
||||
});
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error ?? new Error("v5 seed tx failed"));
|
||||
};
|
||||
req.onerror = () => reject(req.error ?? new Error("v5 seed open failed"));
|
||||
});
|
||||
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
|
||||
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.folderId).toBeNull();
|
||||
});
|
||||
|
||||
test("SaaS v6 database is force-deleted (data lost, schema reset to v9)", async () => {
|
||||
await seedSaasDatabase(6, ["v6-corrupt-file"]);
|
||||
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
|
||||
// Wipe path - files are gone, but the DB is now a clean v9 install.
|
||||
const rows = await readAllFiles();
|
||||
expect(rows).toHaveLength(0);
|
||||
const stores = await getObjectStoreNames();
|
||||
expect(stores).toContain("files");
|
||||
expect(stores).toContain("folders");
|
||||
expect(stores).not.toContain("folder_members");
|
||||
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
|
||||
TARGET_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
test("SaaS v7 database is force-deleted (data lost, schema reset to v9)", async () => {
|
||||
await seedSaasDatabase(7, ["v7-corrupt-file"]);
|
||||
|
||||
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
|
||||
indexedDBManager.closeDatabase(DB_NAME);
|
||||
|
||||
const rows = await readAllFiles();
|
||||
expect(rows).toHaveLength(0);
|
||||
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
|
||||
TARGET_VERSION,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,24 @@ class IndexedDBManager {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
// SaaS lineage shipped a v6 and a v7 of stirling-pdf-files whose
|
||||
// upgrade paths corrupted records (separate cursor walks racing in
|
||||
// one versionchange transaction). The SaaS build wipes those
|
||||
// databases on open to get users unstuck; we carry the wipe forward
|
||||
// here so any SaaS browser that hadn't reopened the app since then
|
||||
// gets a clean v9 install instead of trying to migrate corrupt data.
|
||||
// Affected users have already lost their files - this is just the
|
||||
// recovery path they were already on.
|
||||
if (config.name === "stirling-pdf-files") {
|
||||
const existingVersion = await this.getDatabaseVersion(config.name);
|
||||
if (existingVersion === 6 || existingVersion === 7) {
|
||||
console.warn(
|
||||
`Deleting corrupt SaaS v${existingVersion} ${config.name} database. Files will be lost but the app will work.`,
|
||||
);
|
||||
await this.deleteDatabase(config.name);
|
||||
}
|
||||
}
|
||||
|
||||
const initPromise = this.performDatabaseInit(config);
|
||||
this.initPromises.set(config.name, initPromise);
|
||||
|
||||
@@ -150,6 +168,25 @@ class IndexedDBManager {
|
||||
this.migrateFilesStore(store, oldVersion);
|
||||
}
|
||||
});
|
||||
|
||||
// Drop stores that the SaaS lineage created in v6 but that this
|
||||
// codebase doesn't use. We use a different folder model now
|
||||
// (a `folders` store plus a `folderId` foreign key on each
|
||||
// file row), so folder_members / folder_run_states /
|
||||
// smart_folders are dead weight. The deleteObjectStore calls
|
||||
// must happen inside this versionchange transaction.
|
||||
if (config.name === "stirling-pdf-files") {
|
||||
for (const orphan of [
|
||||
"folder_members",
|
||||
"folder_run_states",
|
||||
"smart_folders",
|
||||
]) {
|
||||
if (db.objectStoreNames.contains(orphan)) {
|
||||
db.deleteObjectStore(orphan);
|
||||
console.info(`Dropped orphan SaaS store: ${orphan}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -173,7 +210,7 @@ class IndexedDBManager {
|
||||
* `if (oldVersion < N) { ... }` sections below.
|
||||
*/
|
||||
private migrateFilesStore(store: IDBObjectStore, oldVersion: number): void {
|
||||
if (oldVersion >= 4) return; // nothing to migrate at the current schema
|
||||
if (oldVersion >= 9) return; // nothing to migrate at the current schema
|
||||
|
||||
const cursor = store.openCursor();
|
||||
let migrated = 0;
|
||||
@@ -213,9 +250,12 @@ class IndexedDBManager {
|
||||
}
|
||||
}
|
||||
|
||||
// v4: folderId. Required to exist on every row so the folderId
|
||||
// index doesn't drop the record out of bounded-key cursor scans.
|
||||
if (oldVersion < 4 && record.folderId === undefined) {
|
||||
// folderId. OSS lineage added this in v4. SaaS lineage never had
|
||||
// it (its v5 and v8 file rows both lack the field), so we gate on
|
||||
// field presence rather than oldVersion. Required on every row
|
||||
// so the folderId index doesn't drop the record out of
|
||||
// bounded-key cursor scans.
|
||||
if (record.folderId === undefined) {
|
||||
record.folderId = null;
|
||||
needsUpdate = true;
|
||||
}
|
||||
@@ -238,7 +278,7 @@ class IndexedDBManager {
|
||||
|
||||
cursor.onerror = (event) => {
|
||||
// Same reasoning as the per-record catch above: abort the upgrade so the
|
||||
// schema doesn't get marked as v4 with rows still on the v3 shape.
|
||||
// schema doesn't get marked as v9 with rows still on the older shape.
|
||||
const err = (event.target as IDBRequest).error;
|
||||
console.error("Files-store migration cursor failed:", err);
|
||||
try {
|
||||
@@ -324,7 +364,7 @@ class IndexedDBManager {
|
||||
export const DATABASE_CONFIGS = {
|
||||
FILES: {
|
||||
name: "stirling-pdf-files",
|
||||
version: 4,
|
||||
version: 9,
|
||||
stores: [
|
||||
{
|
||||
name: "files",
|
||||
|
||||
Reference in New Issue
Block a user