import { IDBFactory } from "fake-indexeddb"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createManualHeartbeatScheduler, createManualOwnerLivenessProvider, requestResult, } 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("unions active owner snapshots and expires stale owners conservatively", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-owner-repository-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); let now = 1_000; const options = { databaseName, indexedDB, now: () => now, ownerLivenessProvider: liveness.provider, ownerTtlMs: 100, staleLeaseMs: 1_000, }; const firstIds = ["owner-one", "owner-one-import", "owner-one-change"]; const firstRepository = createIndexedDbToolcraftBinaryAssetRepository({ ...options, idFactory: () => firstIds.shift() ?? crypto.randomUUID(), }); const secondRepository = createIndexedDbToolcraftBinaryAssetRepository({ ...options, idFactory: () => "owner-two", }); const lease = await firstRepository.beginLease("owner-one-import"); await lease.put("owner-one:reachable", new Uint8Array([1]), binaryOptions); await lease.commit(); // The first owner is active but has not published reachability yet. await expect(secondRepository.collect(new Set())).resolves.toEqual([]); await expect( secondRepository.get("owner-one:reachable"), ).resolves.not.toBeNull(); await firstRepository.collect(new Set(["owner-one:reachable"])); await expect(secondRepository.collect(new Set())).resolves.toEqual([]); await expect( secondRepository.get("owner-one:reachable"), ).resolves.not.toBeNull(); const changedLease = await firstRepository.beginLease("owner-one-change"); await changedLease.put( "owner-one:newly-committed", new Uint8Array([2]), binaryOptions, ); await changedLease.commit(); await expect(secondRepository.collect(new Set())).resolves.toEqual([]); await expect( secondRepository.get("owner-one:newly-committed"), ).resolves.not.toBeNull(); liveness.abandon("owner-one"); now += 101; await expect(secondRepository.collect(new Set())).resolves.toEqual( ["owner-one:newly-committed", "owner-one:reachable"].sort(), ); await expect( secondRepository.get("owner-one:newly-committed"), ).resolves.toBeNull(); await expect(secondRepository.get("owner-one:reachable")).resolves.toBeNull(); }); it("refreshes active leases and reaps only expired abandoned staging", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-stale-lease-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); let now = 2_000; const options = { databaseName, indexedDB, now: () => now, ownerLivenessProvider: liveness.provider, ownerTtlMs: 1_000, staleLeaseMs: 100, }; const firstIds = ["lease-owner", "active-lease"]; const firstRepository = createIndexedDbToolcraftBinaryAssetRepository({ ...options, idFactory: () => firstIds.shift() ?? crypto.randomUUID(), }); const secondRepository = createIndexedDbToolcraftBinaryAssetRepository({ ...options, idFactory: () => "collector-owner", }); const activeLease = await firstRepository.beginLease("eventually-abandoned"); await activeLease.put("staged:only", new Uint8Array([1]), binaryOptions); now += 50; await expect(activeLease.get("staged:only")).resolves.not.toBeNull(); now += 70; await secondRepository.collect(new Set()); await expect(activeLease.get("staged:only")).resolves.not.toBeNull(); liveness.abandon("lease-owner"); now += 101; await secondRepository.collect(new Set()); await expect(activeLease.get("staged:only")).rejects.toThrow(/stale|expired/u); await expect(firstRepository.get("staged:only")).resolves.toBeNull(); }); it.each(["unknown", "throw"] as const)( "retains foreign data when owner liveness is %s", async (queryMode) => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-unknown-liveness-${queryMode}-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); let now = 4_000; const options = { databaseName, indexedDB, now: () => now, ownerLivenessProvider: liveness.provider, ownerTtlMs: 100, staleLeaseMs: 100, }; const firstRepository = createIndexedDbToolcraftBinaryAssetRepository(options); const secondRepository = createIndexedDbToolcraftBinaryAssetRepository(options); const lease = await firstRepository.beginLease("unknown-owner"); await lease.put("unknown:reachable", new Uint8Array([1]), binaryOptions); await lease.commit(); await firstRepository.collect(new Set(["unknown:reachable"])); const staged = await firstRepository.beginLease("unknown-staging"); await staged.put("unknown:staged", new Uint8Array([2]), binaryOptions); now += 1_000; liveness.setQueryMode(queryMode); await expect(secondRepository.collect(new Set())).resolves.toEqual([]); await expect( secondRepository.get("unknown:reachable"), ).resolves.not.toBeNull(); liveness.setQueryMode("available"); await expect(staged.get("unknown:staged")).resolves.not.toBeNull(); }, ); it("releases locks, timers, staging, and the database handle on dispose", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-liveness-dispose-${crypto.randomUUID()}`; const heartbeat = createManualHeartbeatScheduler(); const liveness = createManualOwnerLivenessProvider(); const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName, heartbeatScheduler: heartbeat.scheduler, indexedDB, ownerLivenessProvider: liveness.provider, }); const lease = await repository.beginLease("dispose-staging"); await lease.put("dispose:staged", new Uint8Array([1]), binaryOptions); expect(heartbeat.active()).toBe(true); expect(liveness.heldCount()).toBe(1); await repository.dispose(); expect(heartbeat.active()).toBe(false); expect(liveness.heldCount()).toBe(0); await requestResult(indexedDB.deleteDatabase(databaseName)); }); it("does not poison a job id when lease identity generation fails", async () => { const indexedDB = new IDBFactory(); let idCall = 0; const repository = createIndexedDbToolcraftBinaryAssetRepository({ databaseName: `toolcraft-id-failure-${crypto.randomUUID()}`, idFactory: () => { idCall += 1; if (idCall === 1) return "owner-id"; if (idCall === 2) throw new Error("lease id unavailable"); return `lease-id-${idCall}`; }, indexedDB, }); await expect(repository.beginLease("retry-job")).rejects.toThrow( /lease id unavailable/u, ); const lease = await repository.beginLease("retry-job"); await lease.rollback(); }); it("reports a clean capability error when crypto.randomUUID is unavailable", () => { const databaseName = `toolcraft-no-crypto-${crypto.randomUUID()}`; vi.stubGlobal("crypto", undefined); expect(() => createIndexedDbToolcraftBinaryAssetRepository({ databaseName, indexedDB: new IDBFactory(), }), ).toThrow(/requires crypto\.randomUUID or an idFactory option/u); expect(() => createIndexedDbToolcraftBinaryAssetRepository({ databaseName: `${databaseName}-injected`, idFactory: () => "injected-owner-id", indexedDB: new IDBFactory(), }), ).not.toThrow(); }); });