From 71da39b176aad4cab57767678ffaa03d07b35cbe Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Mon, 28 Sep 2026 11:31:58 +0530 Subject: [PATCH 1/4] fix(web): recover thread loading from closed IndexedDB --- apps/web/src/connection/storage.test.ts | 19 ++ apps/web/src/connection/storage.ts | 180 ++++++++++-------- .../src/state/threads-sync.test.ts | 21 +- 3 files changed, 143 insertions(+), 77 deletions(-) diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 206220768e9a..8cbb1f3b83b1 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -69,6 +69,25 @@ describe("makeCatalogStore", () => { }); describe("makeCatalogBackend", () => { + it.effect("reports a closed IndexedDB connection as a typed read and write failure", () => + Effect.gen(function* () { + vi.stubGlobal("window", {}); + const database = { + transaction: () => { + throw new DOMException("The database connection is closing.", "InvalidStateError"); + }, + } as unknown as IDBDatabase; + const backend = makeCatalogBackend(database); + + const readError = yield* Effect.flip(backend.read); + const writeError = yield* Effect.flip(backend.write("{}")); + + expect(readError).toBeInstanceOf(ConnectionTransientError); + expect(readError.message).toContain("The database connection is closing."); + expect(writeError).toBeInstanceOf(ConnectionTransientError); + }), + ); + it.effect("fails writes when desktop secure storage declines the catalog", () => Effect.gen(function* () { const setConnectionCatalog = vi.fn().mockResolvedValue(false); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index ec2e4ea5175c..06887b14f598 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -141,42 +141,50 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( ); return; } - const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); - request.addEventListener("upgradeneeded", () => { - if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { - request.result.createObjectStore(CATALOG_STORE_NAME); - } - if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { - request.result.createObjectStore(SHELL_STORE_NAME); - } - if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { - request.result.createObjectStore(THREAD_STORE_NAME); - } - if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) { - request.result.createObjectStore(SERVER_CONFIG_STORE_NAME); - } - if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { - request.result.createObjectStore(VCS_REFS_STORE_NAME); - } - }); - request.addEventListener("error", () => { - resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); - }); - request.addEventListener("success", () => { - resume(Effect.succeed(request.result)); - }); + try { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { + request.result.createObjectStore(CATALOG_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { + request.result.createObjectStore(SHELL_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { + request.result.createObjectStore(THREAD_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) { + request.result.createObjectStore(SERVER_CONFIG_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { + request.result.createObjectStore(VCS_REFS_STORE_NAME); + } + }); + request.addEventListener("error", () => { + resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + } catch (cause) { + resume(Effect.fail(catalogError("open", cause))); + } }); }); function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { return Effect.callback((resume) => { - const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); - request.addEventListener("error", () => { - resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); - }); - request.addEventListener("success", () => { - resume(Effect.succeed(request.result)); - }); + try { + const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); + request.addEventListener("error", () => { + resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + } catch (cause) { + resume(Effect.fail(catalogError("read", cause))); + } }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue")); } @@ -187,61 +195,81 @@ function writeDatabaseValue( value: unknown, ) { return Effect.callback((resume) => { - const transaction = database.transaction(storeName, "readwrite"); - // Every failed write fires "abort". A failed commit, such as - // QuotaExceededError, fires only "abort" and no "error". - transaction.addEventListener("abort", () => { - resume( - Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), - ); - }); - transaction.addEventListener("complete", () => { - resume(Effect.void); - }); - transaction.objectStore(storeName).put(value, key); + try { + const transaction = database.transaction(storeName, "readwrite"); + // Every failed write fires "abort". A failed commit, such as + // QuotaExceededError, fires only "abort" and no "error". + transaction.addEventListener("abort", () => { + resume( + Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).put(value, key); + } catch (cause) { + resume(Effect.fail(catalogError("write", cause))); + } }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue")); } function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { return Effect.callback((resume) => { - const transaction = database.transaction(storeName, "readwrite"); - transaction.addEventListener("error", () => { - resume( - Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error")), - ); - }); - transaction.addEventListener("complete", () => { - resume(Effect.void); - }); - transaction.objectStore(storeName).delete(key); + try { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail( + catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error"), + ), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).delete(key); + } catch (cause) { + resume(Effect.fail(catalogError("remove", cause))); + } }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue")); } function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) { return Effect.callback((resume) => { - const transaction = database.transaction(storeName, "readwrite"); - transaction.addEventListener("error", () => { - resume( - Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error")), - ); - }); - transaction.addEventListener("complete", () => { - resume(Effect.void); - }); - const request = transaction.objectStore(storeName).openCursor(range); - request.addEventListener("error", () => { - resume( - Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")), - ); - }); - request.addEventListener("success", () => { - const cursor = request.result; - if (cursor === null) { - return; - } - cursor.delete(); - cursor.continue(); - }); + try { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail( + catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error"), + ), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + const request = transaction.objectStore(storeName).openCursor(range); + request.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")), + ); + }); + request.addEventListener("success", () => { + const cursor = request.result; + if (cursor === null) { + return; + } + try { + cursor.delete(); + cursor.continue(); + } catch (cause) { + resume(Effect.fail(catalogError("remove", cause))); + } + }); + } catch (cause) { + resume(Effect.fail(catalogError("remove", cause))); + } }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange")); } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 848357ec2bef..6335cff10530 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -140,7 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly httpSnapshot?: Option.Option; readonly completionMarker?: boolean; readonly resumeCache?: NonNullable[1]>; - readonly loadCached?: Effect.Effect>; + readonly loadCached?: ReturnType; readonly saveThread?: Persistence.EnvironmentCacheStore["Service"]["saveThread"]; }) { const inputs = yield* Queue.unbounded(); @@ -373,6 +373,25 @@ const deleted = (): OrchestrationThreadStreamItem => ({ }); describe("EnvironmentThreads", () => { + it.effect("loads the server thread when its local cache read fails", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + loadCached: Effect.fail( + new Persistence.ConnectionPersistenceError({ + operation: "load-thread", + message: "The database connection is closing.", + }), + ), + httpSnapshot: Option.some({ snapshotSequence: 7, thread: ACTIVE_THREAD }), + }); + + const state = yield* awaitThreadState(h.observed, (value) => value.status === "live"); + expect(Option.getOrThrow(state.data)).toEqual(ACTIVE_THREAD); + expect(yield* Ref.get(h.loaderCalls)).toBe(1); + expect(yield* Ref.get(h.subscriptionCount)).toBe(1); + }), + ); + for (const source of ["disk", "HTTP"] as const) { it.effect(`does not rewrite an unchanged ${source} snapshot on navigation or warm return`, () => Effect.gen(function* () { From fabbef730f3fce5e47fda6fca7de390b348786db Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Mon, 28 Sep 2026 11:41:06 +0530 Subject: [PATCH 2/4] fix(web): settle aborted IndexedDB removals --- apps/web/src/connection/storage.test.ts | 51 ++++++++++++++++++++++++- apps/web/src/connection/storage.ts | 4 +- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 8cbb1f3b83b1..fe75f21af18b 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -2,8 +2,8 @@ import { ConnectionTransientError, PrimaryConnectionTarget, } from "@t3tools/client-runtime/connection"; -import { EnvironmentId } from "@t3tools/contracts"; -import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { ConnectionCatalogDocument, EnvironmentCacheStore } from "@t3tools/client-runtime/platform"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; @@ -16,6 +16,7 @@ import { makeBrowserGitHubRoutingPermissions, makeCatalogBackend, makeCatalogStore, + connectionStorageLayer, } from "./storage"; const emptyCatalog = { @@ -131,6 +132,52 @@ describe("makeCatalogBackend", () => { ); }); +describe("environment cache removal", () => { + it.effect("fails both removal operations when IndexedDB aborts their commits", () => + Effect.gen(function* () { + vi.stubGlobal("window", {}); + vi.stubGlobal("IDBKeyRange", { bound: () => ({}) }); + const database = { + transaction: () => { + const transaction = Object.assign(new EventTarget(), { + error: new DOMException("Commit aborted", "AbortError"), + objectStore: () => ({ + delete: () => queueMicrotask(() => transaction.dispatchEvent(new Event("abort"))), + openCursor: () => { + queueMicrotask(() => transaction.dispatchEvent(new Event("abort"))); + return new EventTarget(); + }, + }), + }); + return transaction; + }, + close: vi.fn(), + } as unknown as IDBDatabase; + const openRequest = Object.assign(new EventTarget(), { result: database, error: null }); + vi.stubGlobal("indexedDB", { + open: () => { + queueMicrotask(() => openRequest.dispatchEvent(new Event("success"))); + return openRequest; + }, + }); + + const [threadError, refsError] = yield* Effect.gen(function* () { + const cache = yield* EnvironmentCacheStore; + return [ + yield* Effect.flip( + cache.removeThread(EnvironmentId.make("env"), ThreadId.make("thread")), + ), + yield* Effect.flip(cache.clearVcsRefs(EnvironmentId.make("env"))), + ] as const; + }).pipe(Effect.provide(connectionStorageLayer)); + + expect(threadError.message).toContain("Commit aborted"); + expect(refsError.message).toContain("Commit aborted"); + expect(database.close).toHaveBeenCalledOnce(); + }), + ); +}); + describe("browser GitHub routing permissions", () => { it.effect("revokes across runtimes before storage events and resists stale catalog writes", () => Effect.gen(function* () { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 06887b14f598..408fbfc55b56 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -218,7 +218,7 @@ function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBV return Effect.callback((resume) => { try { const transaction = database.transaction(storeName, "readwrite"); - transaction.addEventListener("error", () => { + transaction.addEventListener("abort", () => { resume( Effect.fail( catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error"), @@ -239,7 +239,7 @@ function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, r return Effect.callback((resume) => { try { const transaction = database.transaction(storeName, "readwrite"); - transaction.addEventListener("error", () => { + transaction.addEventListener("abort", () => { resume( Effect.fail( catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error"), From a52d78a4095b5951627c1a93563b2fc42b8a72db Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Mon, 28 Sep 2026 16:05:28 +0530 Subject: [PATCH 3/4] fix(web): reopen IndexedDB after forced closure --- apps/web/src/connection/storage.test.ts | 68 +++++++++++++++++-- apps/web/src/connection/storage.ts | 88 +++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index fe75f21af18b..0c2192c0218e 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -78,7 +78,7 @@ describe("makeCatalogBackend", () => { throw new DOMException("The database connection is closing.", "InvalidStateError"); }, } as unknown as IDBDatabase; - const backend = makeCatalogBackend(database); + const backend = makeCatalogBackend(Effect.succeed(database)); const readError = yield* Effect.flip(backend.read); const writeError = yield* Effect.flip(backend.write("{}")); @@ -98,7 +98,7 @@ describe("makeCatalogBackend", () => { setConnectionCatalog, }, }); - const backend = makeCatalogBackend({} as IDBDatabase); + const backend = makeCatalogBackend(Effect.succeed({} as IDBDatabase)); const error = yield* backend.write("{}").pipe(Effect.flip); @@ -123,7 +123,9 @@ describe("makeCatalogBackend", () => { }, }), }); - const backend = makeCatalogBackend({ transaction: () => transaction } as never); + const backend = makeCatalogBackend( + Effect.succeed({ transaction: () => transaction } as never), + ); const error = yield* backend.write("{}").pipe(Effect.flip); @@ -137,7 +139,7 @@ describe("environment cache removal", () => { Effect.gen(function* () { vi.stubGlobal("window", {}); vi.stubGlobal("IDBKeyRange", { bound: () => ({}) }); - const database = { + const database = Object.assign(new EventTarget(), { transaction: () => { const transaction = Object.assign(new EventTarget(), { error: new DOMException("Commit aborted", "AbortError"), @@ -152,7 +154,7 @@ describe("environment cache removal", () => { return transaction; }, close: vi.fn(), - } as unknown as IDBDatabase; + }) as unknown as IDBDatabase; const openRequest = Object.assign(new EventTarget(), { result: database, error: null }); vi.stubGlobal("indexedDB", { open: () => { @@ -178,6 +180,62 @@ describe("environment cache removal", () => { ); }); +describe("IndexedDB connection recovery", () => { + it.effect("reopens after a forced close and finalizes the current connection", () => + Effect.gen(function* () { + vi.stubGlobal("window", {}); + const makeDatabase = () => + Object.assign(new EventTarget(), { + close: vi.fn(), + transaction: () => ({ + objectStore: () => ({ + get: () => { + const request = Object.assign(new EventTarget(), { + result: undefined, + error: null, + }); + queueMicrotask(() => request.dispatchEvent(new Event("success"))); + return request; + }, + }), + }), + }) as unknown as IDBDatabase; + const first = makeDatabase(); + const second = makeDatabase(); + const databases = [first, second]; + let openCount = 0; + const open = vi.fn(() => { + const request = Object.assign(new EventTarget(), { + result: databases[openCount++], + error: null, + }); + queueMicrotask(() => request.dispatchEvent(new Event("success"))); + return request; + }); + vi.stubGlobal("indexedDB", { open }); + + yield* Effect.gen(function* () { + const cache = yield* EnvironmentCacheStore; + const environmentId = EnvironmentId.make("env"); + const threadId = ThreadId.make("thread"); + expect(Option.isNone(yield* cache.loadThread(environmentId, threadId))).toBe(true); + expect(open).toHaveBeenCalledTimes(1); + + first.dispatchEvent(new Event("close")); + const recovered = yield* Effect.all( + [cache.loadThread(environmentId, threadId), cache.loadThread(environmentId, threadId)], + { concurrency: 2 }, + ); + expect(recovered.every(Option.isNone)).toBe(true); + expect(open).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide(connectionStorageLayer)); + + expect(first.close).not.toHaveBeenCalled(); + expect(second.close).toHaveBeenCalledOnce(); + }), + ); +}); + describe("browser GitHub routing permissions", () => { it.effect("revokes across runtimes before storage events and resists stale catalog writes", () => Effect.gen(function* () { diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 408fbfc55b56..3ebd5ad89290 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -172,7 +172,38 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( }); }); -function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { +type DatabaseHandle = Effect.Effect; + +/** Share a connection until the browser closes it; the next access reopens it. */ +const makeDatabaseHandle = Effect.fn("web.connectionStorage.makeDatabaseHandle")(function* () { + const lock = yield* Semaphore.make(1); + let current: IDBDatabase | null = null; + const get: DatabaseHandle = Effect.suspend(() => + current !== null + ? Effect.succeed(current) + : lock.withPermits(1)( + Effect.gen(function* () { + if (current !== null) return current; + const opened = yield* openDatabase(); + current = opened; + opened.addEventListener("close", () => { + if (current === opened) current = null; + }); + return opened; + }), + ), + ); + yield* get; + const close = lock.withPermits(1)( + Effect.sync(() => { + current?.close(); + current = null; + }), + ); + return { get, close }; +}); + +function readDatabaseValueOnConnection(database: IDBDatabase, storeName: string, key: IDBValidKey) { return Effect.callback((resume) => { try { const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); @@ -188,7 +219,7 @@ function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBVal }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue")); } -function writeDatabaseValue( +function writeDatabaseValueOnConnection( database: IDBDatabase, storeName: string, key: IDBValidKey, @@ -214,7 +245,11 @@ function writeDatabaseValue( }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue")); } -function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { +function removeDatabaseValueOnConnection( + database: IDBDatabase, + storeName: string, + key: IDBValidKey, +) { return Effect.callback((resume) => { try { const transaction = database.transaction(storeName, "readwrite"); @@ -235,7 +270,11 @@ function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBV }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue")); } -function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) { +function removeDatabaseValuesInRangeOnConnection( + database: IDBDatabase, + storeName: string, + range: IDBKeyRange, +) { return Effect.callback((resume) => { try { const transaction = database.transaction(storeName, "readwrite"); @@ -273,6 +312,39 @@ function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, r }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange")); } +function readDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) { + return Effect.flatMap(database, (opened) => + readDatabaseValueOnConnection(opened, storeName, key), + ); +} + +function writeDatabaseValue( + database: DatabaseHandle, + storeName: string, + key: IDBValidKey, + value: unknown, +) { + return Effect.flatMap(database, (opened) => + writeDatabaseValueOnConnection(opened, storeName, key, value), + ); +} + +function removeDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) { + return Effect.flatMap(database, (opened) => + removeDatabaseValueOnConnection(opened, storeName, key), + ); +} + +function removeDatabaseValuesInRange( + database: DatabaseHandle, + storeName: string, + range: IDBKeyRange, +) { + return Effect.flatMap(database, (opened) => + removeDatabaseValuesInRangeOnConnection(opened, storeName, range), + ); +} + function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { return `${environmentId}:${threadId}`; } @@ -301,7 +373,7 @@ export interface CatalogBackend { readonly quarantine?: (raw: string) => Effect.Effect; } -export function makeCatalogBackend(database: IDBDatabase): CatalogBackend { +export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend { const bridge = window.desktopBridge; if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) { return { @@ -510,9 +582,11 @@ export function makeBrowserGitHubRoutingPermissions( export const connectionStorageLayer = Layer.effectContext( Effect.gen(function* () { - const database = yield* Effect.acquireRelease(openDatabase(), (database) => - Effect.sync(() => database.close()), + const databaseHandle = yield* Effect.acquireRelease( + makeDatabaseHandle(), + (handle) => handle.close, ); + const database = databaseHandle.get; const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions(); From 58fd5b06fc2ab59d15d6f21e5a7dfb2afde2eee8 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Mon, 28 Sep 2026 16:13:14 +0530 Subject: [PATCH 4/4] fix(web): defer IndexedDB opening to storage operations --- apps/web/src/connection/storage.test.ts | 21 +++++++++++++++++++++ apps/web/src/connection/storage.ts | 1 - 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 0c2192c0218e..cdf3a90600fd 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -181,6 +181,27 @@ describe("environment cache removal", () => { }); describe("IndexedDB connection recovery", () => { + it.effect("reports an initial open failure from the cache operation", () => + Effect.gen(function* () { + vi.stubGlobal("window", {}); + const open = vi.fn(() => { + throw new DOMException("Storage is unavailable", "InvalidStateError"); + }); + vi.stubGlobal("indexedDB", { open }); + + yield* Effect.gen(function* () { + const cache = yield* EnvironmentCacheStore; + expect(open).not.toHaveBeenCalled(); + const error = yield* Effect.flip( + cache.loadThread(EnvironmentId.make("env"), ThreadId.make("thread")), + ); + expect(error.message).toContain("Storage is unavailable"); + }).pipe(Effect.provide(connectionStorageLayer)); + + expect(open).toHaveBeenCalledOnce(); + }), + ); + it.effect("reopens after a forced close and finalizes the current connection", () => Effect.gen(function* () { vi.stubGlobal("window", {}); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 3ebd5ad89290..63fd4d727aa2 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -193,7 +193,6 @@ const makeDatabaseHandle = Effect.fn("web.connectionStorage.makeDatabaseHandle") }), ), ); - yield* get; const close = lock.withPermits(1)( Effect.sync(() => { current?.close();