import { IDBFactory } from "fake-indexeddb"; import { afterEach, describe, expect, it } from "vitest"; import { createManualOwnerLivenessProvider, openVersionOneDatabase, requestResult, transactionDone, } from "./indexeddb-binary-asset-test-support"; import { binaryOptions, cleanupIndexedDbRepositories, createIndexedDbToolcraftBinaryAssetRepository, } from "./indexeddb-binary-asset-repository.test-support"; afterEach(cleanupIndexedDbRepositories); describe("IndexedDB binary asset repository", () => { it("migrates v1 by clearing legacy staging while preserving committed entries", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-v1-migration-${crypto.randomUUID()}`; const versionOne = await openVersionOneDatabase(indexedDB, databaseName); const write = versionOne.transaction(["entries", "leases"], "readwrite"); const written = transactionDone(write); write.objectStore("entries").put({ bytes: new Uint8Array([1]), contentType: "model/gltf-binary", durable: true, ref: "committed:v1", }); write.objectStore("leases").put({ bytes: new Uint8Array([2]), contentType: "model/gltf-binary", durable: true, jobId: "legacy-job", leaseId: "legacy-lease", ref: "staged:v1", }); await written; versionOne.close(); const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, }); await expect(repository.get("committed:v1")).resolves.toMatchObject({ bytes: new Uint8Array([1]), }); const inspection = await requestResult(indexedDB.open(databaseName, 2)); const read = inspection.transaction(["entries", "leases"], "readonly"); const readDone = transactionDone(read); const [committed, legacyLeaseKeys] = await Promise.all([ requestResult(read.objectStore("entries").get("committed:v1")), requestResult(read.objectStore("leases").getAllKeys()), ]); await readDone; inspection.close(); expect(committed).toBeDefined(); expect(legacyLeaseKeys).toEqual([]); }); it("rejects a blocked upgrade and clears the failed open for retry", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-blocked-open-${crypto.randomUUID()}`; const blocker = await openVersionOneDatabase(indexedDB, databaseName); const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, }); await expect(repository.get("missing")).rejects.toThrow(/blocked/u); blocker.close(); await expect(repository.get("missing")).resolves.toBeNull(); }); it("aborts an abandoned blocked upgrade before mutating the v1 database", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-abandoned-blocked-open-${crypto.randomUUID()}`; const blocker = await openVersionOneDatabase(indexedDB, databaseName); const write = blocker.transaction(["entries", "leases"], "readwrite"); const written = transactionDone(write); write.objectStore("entries").put({ bytes: new Uint8Array([1]), contentType: "model/gltf-binary", durable: true, ref: "committed:v1-blocked", }); write.objectStore("leases").put({ bytes: new Uint8Array([2]), contentType: "model/gltf-binary", durable: true, jobId: "legacy-job", leaseId: "legacy-lease", ref: "staged:v1-blocked", }); await written; const liveness = createManualOwnerLivenessProvider(); const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, ownerLivenessProvider: liveness.provider, }); await expect(repository.get("missing")).rejects.toThrow(/blocked/u); await repository.dispose(); expect(liveness.heldCount()).toBe(0); blocker.close(); const inspection = await requestResult(indexedDB.open(databaseName)); const version = inspection.version; const hasMetadataStore = inspection.objectStoreNames.contains("metadata"); const read = inspection.transaction(["entries", "leases"], "readonly"); const readDone = transactionDone(read); const [entryKeys, leaseKeys] = await Promise.all([ requestResult(read.objectStore("entries").getAllKeys()), requestResult(read.objectStore("leases").getAllKeys()), ]); await readDone; inspection.close(); expect(version).toBe(1); expect(hasMetadataStore).toBe(false); expect(entryKeys).toEqual(["committed:v1-blocked"]); expect(leaseKeys).toEqual([["legacy-lease", "staged:v1-blocked"]]); }); it("closes on versionchange and reopens after cooperative database deletion", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-versionchange-${crypto.randomUUID()}`; const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, }); await expect(repository.get("missing")).resolves.toBeNull(); await requestResult(indexedDB.deleteDatabase(databaseName)); await expect(repository.get("missing")).resolves.toBeNull(); }); it("reopens the current version to clean owner state after versionchange", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-versionchange-dispose-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, ownerLivenessProvider: liveness.provider, }); const committed = await repository.beginLease("committed-before-upgrade"); await committed.put("committed:survives", new Uint8Array([1]), binaryOptions); await committed.commit(); const staged = await repository.beginLease("staged-before-upgrade"); await staged.put("staged:removed", new Uint8Array([2]), binaryOptions); const upgraded = await requestResult(indexedDB.open(databaseName, 3)); expect(upgraded.version).toBe(3); expect(liveness.heldCount()).toBe(1); await repository.dispose(); expect(liveness.heldCount()).toBe(0); const read = upgraded.transaction( ["entries", "leases", "metadata"], "readonly", ); const readDone = transactionDone(read); const [entryKeys, leaseKeys, metadataKeys] = await Promise.all([ requestResult(read.objectStore("entries").getAllKeys()), requestResult(read.objectStore("leases").getAllKeys()), requestResult(read.objectStore("metadata").getAllKeys()), ]); await readDone; upgraded.close(); expect(entryKeys).toEqual(["committed:survives"]); expect(leaseKeys).toEqual([]); expect(metadataKeys).toEqual([]); }); it("does not recreate a deleted database during disposal cleanup", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-deleted-before-dispose-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, ownerLivenessProvider: liveness.provider, }); await expect(repository.get("missing")).resolves.toBeNull(); await requestResult(indexedDB.deleteDatabase(databaseName)); await repository.dispose(); expect(liveness.heldCount()).toBe(0); await expect(indexedDB.databases()).resolves.not.toContainEqual( expect.objectContaining({ name: databaseName }), ); }); });