diff --git a/frontend/editor/src/core/services/indexedDBManager.migration.test.ts b/frontend/editor/src/core/services/indexedDBManager.migration.test.ts index 3229ed16e..aecb75484 100644 --- a/frontend/editor/src/core/services/indexedDBManager.migration.test.ts +++ b/frontend/editor/src/core/services/indexedDBManager.migration.test.ts @@ -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 { }); } +/** + * 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 { + 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 { + 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 { 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>; + 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((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>; + 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, + ); + }); }); diff --git a/frontend/editor/src/core/services/indexedDBManager.ts b/frontend/editor/src/core/services/indexedDBManager.ts index 215c41ae3..043b2e438 100644 --- a/frontend/editor/src/core/services/indexedDBManager.ts +++ b/frontend/editor/src/core/services/indexedDBManager.ts @@ -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",