import { IDBFactory } from "fake-indexeddb"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createManualHeartbeatScheduler, createManualOwnerLivenessProvider, 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("acquires owner liveness before opening IndexedDB", async () => { const indexedDB = new IDBFactory(); const open = vi.spyOn(indexedDB, "open"); const liveness = createManualOwnerLivenessProvider(); const acquisition = liveness.pauseNextAcquire(); const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName: `toolcraft-acquire-before-open-${crypto.randomUUID()}`, indexedDB, ownerLivenessProvider: liveness.provider, }); const read = repository.get("missing"); await acquisition.started; expect(open).not.toHaveBeenCalled(); acquisition.resume(); await expect(read).resolves.toBeNull(); expect(open).toHaveBeenCalledTimes(1); }); it("serializes a new owner behind a collector liveness snapshot", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-coordinated-owner-race-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); const collector = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, idFactory: () => "collector-owner", indexedDB, ownerLivenessProvider: liveness.provider, }); const newcomerIds = ["new-owner", "new-owner-lease"]; const newcomer = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, idFactory: () => newcomerIds.shift() ?? crypto.randomUUID(), indexedDB, ownerLivenessProvider: liveness.provider, }); await collector.get("missing"); const query = liveness.pauseNextQuery(); const collection = collector.collect(new Set()); await query.started; const requestsBeforeNewcomer = liveness.coordinationRequestCount(); const attemptsBeforeNewcomer = liveness.acquireAttemptCount(); const beginLease = newcomer.beginLease("coordinated-new-owner"); const requestDelta = liveness.coordinationRequestCount() - requestsBeforeNewcomer; const attemptDelta = liveness.acquireAttemptCount() - attemptsBeforeNewcomer; const activeWhilePaused = liveness.activeCoordinationCount(); query.resume(); await collection; const lease = await beginLease; expect(requestDelta).toBe(1); expect(attemptDelta).toBe(0); expect(activeWhilePaused).toBe(1); expect(liveness.acquireAttemptCount() - attemptsBeforeNewcomer).toBe(1); await lease.put("new-owner:live", new Uint8Array([1]), binaryOptions); await lease.commit(); await newcomer.collect(new Set(["new-owner:live"])); await expect(collector.collect(new Set())).resolves.toEqual([]); await expect(collector.get("new-owner:live")).resolves.not.toBeNull(); }); it("waits for an admitted operation before cleanup and lock release", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-dispose-operation-barrier-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, ownerLivenessProvider: liveness.provider, }); const lease = await repository.beginLease("paused-operation"); const query = liveness.pauseNextQuery(); const put = lease.put("paused:staged", new Uint8Array([1]), binaryOptions); await query.started; let disposed = false; const disposal = repository.dispose().then(() => { disposed = true; }); await Promise.resolve(); expect(disposed).toBe(false); expect(liveness.heldCount()).toBe(1); await expect(repository.get("later")).rejects.toThrow(/disposed/u); query.resume(); await expect(put).resolves.toBeUndefined(); await disposal; expect(liveness.heldCount()).toBe(0); const inspection = await requestResult(indexedDB.open(databaseName)); const read = inspection.transaction(["leases", "metadata"], "readonly"); const readDone = transactionDone(read); const [leaseKeys, metadataKeys] = await Promise.all([ requestResult(read.objectStore("leases").getAllKeys()), requestResult(read.objectStore("metadata").getAllKeys()), ]); await readDone; inspection.close(); expect(leaseKeys).toEqual([]); expect(metadataKeys).toEqual([]); }); it("keeps an idle locked owner reachable when heartbeats are suspended", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-owner-heartbeat-${crypto.randomUUID()}`; const firstHeartbeat = createManualHeartbeatScheduler(); const secondHeartbeat = createManualHeartbeatScheduler(); const liveness = createManualOwnerLivenessProvider(); let now = 1_000; const sharedOptions = { databaseName, heartbeatIntervalMs: 25, indexedDB, now: () => now, ownerLivenessProvider: liveness.provider, ownerTtlMs: 100, staleLeaseMs: 100, }; const firstRepository = createIndexedDbToolcraftBinaryAssetRepository({ ...sharedOptions, heartbeatScheduler: firstHeartbeat.scheduler, }); const secondRepository = createIndexedDbToolcraftBinaryAssetRepository({ ...sharedOptions, heartbeatScheduler: secondHeartbeat.scheduler, }); const lease = await firstRepository.beginLease("idle-owner-import"); await lease.put("idle-owner:reachable", new Uint8Array([1]), binaryOptions); await lease.commit(); await firstRepository.collect(new Set(["idle-owner:reachable"])); await expect(firstRepository.get("idle-owner:reachable")).resolves.not.toBeNull(); now += 1_000; await expect(secondRepository.collect(new Set())).resolves.toEqual([]); await expect( secondRepository.get("idle-owner:reachable"), ).resolves.not.toBeNull(); await firstRepository.dispose(); expect(firstHeartbeat.active()).toBe(false); await expect(secondRepository.collect(new Set())).resolves.toEqual([ "idle-owner:reachable", ]); }); it("keeps a long-running worker lease alive without lease calls", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-lease-heartbeat-${crypto.randomUUID()}`; const workerHeartbeat = createManualHeartbeatScheduler(); const liveness = createManualOwnerLivenessProvider(); let now = 2_000; const sharedOptions = { databaseName, heartbeatIntervalMs: 25, indexedDB, now: () => now, ownerLivenessProvider: liveness.provider, ownerTtlMs: 100, staleLeaseMs: 100, }; const workerRepository = createIndexedDbToolcraftBinaryAssetRepository({ ...sharedOptions, heartbeatScheduler: workerHeartbeat.scheduler, }); const collectorRepository = createIndexedDbToolcraftBinaryAssetRepository( sharedOptions, ); const workerLease = await workerRepository.beginLease("long-worker"); await workerLease.put("worker:staged", new Uint8Array([1]), binaryOptions); now += 1_000; await collectorRepository.collect(new Set()); await expect(workerLease.get("worker:staged")).resolves.not.toBeNull(); }); it("disposes only its owner metadata and staging while invalidating its API", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-owner-dispose-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); const firstRepository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, ownerLivenessProvider: liveness.provider, }); const secondRepository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB, ownerLivenessProvider: liveness.provider, }); const committedLease = await firstRepository.beginLease("first-committed"); await committedLease.put("first:committed", new Uint8Array([1]), binaryOptions); await committedLease.commit(); const firstStaged = await firstRepository.beginLease("first-staged"); const secondStaged = await secondRepository.beginLease("second-staged"); await firstStaged.put("first:staged", new Uint8Array([2]), binaryOptions); await secondStaged.put("second:staged", new Uint8Array([3]), binaryOptions); expect(liveness.heldCount()).toBe(2); await firstRepository.dispose(); await expect(firstRepository.dispose()).resolves.toBeUndefined(); expect(liveness.heldCount()).toBe(1); await expect(firstRepository.beginLease("later")).rejects.toThrow(/disposed/u); await expect(firstRepository.get("first:committed")).rejects.toThrow(/disposed/u); await expect(firstRepository.collect(new Set())).rejects.toThrow(/disposed/u); await expect(firstStaged.get("first:staged")).rejects.toThrow(/disposed/u); expect(firstStaged.refs()).toEqual([]); await expect(secondStaged.get("second:staged")).resolves.not.toBeNull(); await expect(secondStaged.get("first:staged")).resolves.toBeNull(); await expect(secondRepository.collect(new Set())).resolves.toEqual([ "first:committed", ]); await secondRepository.dispose(); expect(liveness.heldCount()).toBe(0); }); });