import { IDBFactory } from "fake-indexeddb"; import { describe, expect, it } from "vitest"; import { createManualHeartbeatScheduler, createManualOwnerLivenessProvider, requestResult, } from "./indexeddb-binary-asset-test-support"; import { createIndexedDbRepositorySetup } from "./indexeddb-binary-asset-options"; function createLifecycle( indexedDB: IDBFactory, databaseName: string, ownerId: string, liveness: ReturnType, ) { return createIndexedDbRepositorySetup({ databaseName, heartbeatScheduler: createManualHeartbeatScheduler().scheduler, idFactory: () => ownerId, indexedDB, ownerLivenessProvider: liveness.provider, }).lifecycle; } describe("IndexedDB binary asset lifecycle coordination", () => { it("rejects duplicate owner locks without leaking coordination and permits retry", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-owner-collision-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); const first = createLifecycle( indexedDB, databaseName, "shared-owner", liveness, ); const second = createLifecycle( indexedDB, databaseName, "shared-owner", liveness, ); try { await first.runOperation(async () => undefined); await expect( second.runOperation(async () => undefined), ).rejects.toThrow(/owner.*collision|already held/u); expect(liveness.activeCoordinationCount()).toBe(0); expect(liveness.heldCount()).toBe(1); await first.dispose(); await expect(second.runOperation(async () => undefined)).resolves.toBeUndefined(); expect(liveness.heldCount()).toBe(1); } finally { await Promise.allSettled([first.dispose(), second.dispose()]); } expect(liveness.activeCoordinationCount()).toBe(0); expect(liveness.heldCount()).toBe(0); }); it("holds coordination through the complete operation callback", async () => { const indexedDB = new IDBFactory(); const databaseName = `toolcraft-operation-coordination-${crypto.randomUUID()}`; const liveness = createManualOwnerLivenessProvider(); const first = createLifecycle(indexedDB, databaseName, "first-owner", liveness); const second = createLifecycle( indexedDB, databaseName, "second-owner", liveness, ); let markCallbackEntered!: () => void; let resumeCallback!: () => void; const callbackEntered = new Promise((resolve) => { markCallbackEntered = resolve; }); const callbackResume = new Promise((resolve) => { resumeCallback = resolve; }); try { const firstOperation = first.runOperation(async ({ database }) => { const transaction = database.transaction("entries", "readonly"); await requestResult(transaction.objectStore("entries").get("missing")); markCallbackEntered(); await callbackResume; }); await callbackEntered; const requestsBefore = liveness.coordinationRequestCount(); const attemptsBefore = liveness.acquireAttemptCount(); const secondOperation = second.runOperation(async () => undefined); const requestDelta = liveness.coordinationRequestCount() - requestsBefore; const attemptDelta = liveness.acquireAttemptCount() - attemptsBefore; const activeWhilePaused = liveness.activeCoordinationCount(); resumeCallback(); await Promise.all([firstOperation, secondOperation]); expect(requestDelta).toBe(1); expect(attemptDelta).toBe(0); expect(activeWhilePaused).toBe(1); expect(liveness.acquireAttemptCount() - attemptsBefore).toBe(1); } finally { resumeCallback(); await Promise.allSettled([first.dispose(), second.dispose()]); } }); });