authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 19:50:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 19:50:38-07:00
log9c49228f5425f644cff9b41ae88ca564d40d79fa
tree336990d220985289f1864f3321276de7c9fcad9b
parent5afe18eaf9d20c1633b4ec9177c6c9309a8f3d99
signature Signed by PGP key B5690EEEBB952194

fix(web): recover from a closed IndexedDB connection (#16311)

Co-authored-by: Lakshmi Tanmay <lakshmi@voltcrash.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 449 insertions(+), 88 deletions(-)

apps/web/src/connection/storage.test.ts+199-4
...@@ -2,8 +2,8 @@ import {...@@ -2,8 +2,8 @@ import {
2 ConnectionTransientError,2 ConnectionTransientError,
3 PrimaryConnectionTarget,3 PrimaryConnectionTarget,
4} from "@t3tools/client-runtime/connection";4} from "@t3tools/client-runtime/connection";
5import { EnvironmentId } from "@t3tools/contracts";5import { EnvironmentId, ThreadId } from "@t3tools/contracts";
6import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";6import { ConnectionCatalogDocument, Persistence } from "@t3tools/client-runtime/platform";
7import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Deferred from "effect/Deferred";9import * as Deferred from "effect/Deferred";
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 makeBrowserGitHubRoutingPermissions,16 makeBrowserGitHubRoutingPermissions,
17 makeCatalogBackend,17 makeCatalogBackend,
18 makeCatalogStore,18 makeCatalogStore,
19 layer as connectionStorageLayer,
19} from "./storage";20} from "./storage";
2021
21const emptyCatalog = {22const emptyCatalog = {
...@@ -68,7 +69,31 @@ describe("makeCatalogStore", () => {...@@ -68,7 +69,31 @@ describe("makeCatalogStore", () => {
68 );69 );
69});70});
7071
72const fixedHandle = (database: IDBDatabase) => ({
73 get: Effect.succeed(database),
74 invalidate: () => Effect.void,
75});
76
71describe("makeCatalogBackend", () => {77describe("makeCatalogBackend", () => {
78 it.effect("reports a closed IndexedDB connection as a typed read and write failure", () =>
79 Effect.gen(function* () {
80 vi.stubGlobal("window", {});
81 const database = {
82 transaction: () => {
83 throw new DOMException("The database connection is closing.", "InvalidStateError");
84 },
85 } as unknown as IDBDatabase;
86 const backend = makeCatalogBackend(fixedHandle(database));
87
88 const readError = yield* Effect.flip(backend.read);
89 const writeError = yield* Effect.flip(backend.write("{}"));
90
91 expect(readError).toBeInstanceOf(ConnectionTransientError);
92 expect(readError.message).toContain("The database connection is closing.");
93 expect(writeError).toBeInstanceOf(ConnectionTransientError);
94 }),
95 );
96
72 it.effect("fails writes when desktop secure storage declines the catalog", () =>97 it.effect("fails writes when desktop secure storage declines the catalog", () =>
73 Effect.gen(function* () {98 Effect.gen(function* () {
74 const setConnectionCatalog = vi.fn().mockResolvedValue(false);99 const setConnectionCatalog = vi.fn().mockResolvedValue(false);
...@@ -78,7 +103,7 @@ describe("makeCatalogBackend", () => {...@@ -78,7 +103,7 @@ describe("makeCatalogBackend", () => {
78 setConnectionCatalog,103 setConnectionCatalog,
79 },104 },
80 });105 });
81 const backend = makeCatalogBackend({} as IDBDatabase);106 const backend = makeCatalogBackend(fixedHandle({} as IDBDatabase));
82107
83 const error = yield* backend.write("{}").pipe(Effect.flip);108 const error = yield* backend.write("{}").pipe(Effect.flip);
84109
...@@ -103,7 +128,9 @@ describe("makeCatalogBackend", () => {...@@ -103,7 +128,9 @@ describe("makeCatalogBackend", () => {
103 },128 },
104 }),129 }),
105 });130 });
106 const backend = makeCatalogBackend({ transaction: () => transaction } as never);131 const backend = makeCatalogBackend(
132 fixedHandle({ transaction: () => transaction } as unknown as IDBDatabase),
133 );
107134
108 const error = yield* backend.write("{}").pipe(Effect.flip);135 const error = yield* backend.write("{}").pipe(Effect.flip);
109136
...@@ -112,6 +139,174 @@ describe("makeCatalogBackend", () => {...@@ -112,6 +139,174 @@ describe("makeCatalogBackend", () => {
112 );139 );
113});140});
114141
142describe("environment cache removal", () => {
143 it.effect("fails both removal operations when IndexedDB aborts their commits", () =>
144 Effect.gen(function* () {
145 vi.stubGlobal("window", {});
146 vi.stubGlobal("IDBKeyRange", { bound: () => ({}) });
147 const database = Object.assign(new EventTarget(), {
148 transaction: () => {
149 const transaction = Object.assign(new EventTarget(), {
150 error: new DOMException("Commit aborted", "AbortError"),
151 objectStore: () => ({
152 delete: () => queueMicrotask(() => transaction.dispatchEvent(new Event("abort"))),
153 openCursor: () => {
154 queueMicrotask(() => transaction.dispatchEvent(new Event("abort")));
155 return new EventTarget();
156 },
157 }),
158 });
159 return transaction;
160 },
161 close: vi.fn(),
162 }) as unknown as IDBDatabase;
163 const openRequest = Object.assign(new EventTarget(), { result: database, error: null });
164 vi.stubGlobal("indexedDB", {
165 open: () => {
166 queueMicrotask(() => openRequest.dispatchEvent(new Event("success")));
167 return openRequest;
168 },
169 });
170
171 const [threadError, refsError] = yield* Effect.gen(function* () {
172 const cache = yield* Persistence.EnvironmentCacheStore;
173 return [
174 yield* Effect.flip(
175 cache.removeThread(EnvironmentId.make("env"), ThreadId.make("thread")),
176 ),
177 yield* Effect.flip(cache.clearVcsRefs(EnvironmentId.make("env"))),
178 ] as const;
179 }).pipe(Effect.provide(connectionStorageLayer));
180
181 expect(threadError.message).toContain("Commit aborted");
182 expect(refsError.message).toContain("Commit aborted");
183 expect(database.close).toHaveBeenCalledOnce();
184 }),
185 );
186});
187
188describe("IndexedDB connection recovery", () => {
189 it.effect("reports an initial open failure from the cache operation", () =>
190 Effect.gen(function* () {
191 vi.stubGlobal("window", {});
192 const open = vi.fn(() => {
193 throw new DOMException("Storage is unavailable", "InvalidStateError");
194 });
195 vi.stubGlobal("indexedDB", { open });
196
197 yield* Effect.gen(function* () {
198 const cache = yield* Persistence.EnvironmentCacheStore;
199 expect(open).not.toHaveBeenCalled();
200 const error = yield* Effect.flip(
201 cache.loadThread(EnvironmentId.make("env"), ThreadId.make("thread")),
202 );
203 expect(error.message).toContain("Storage is unavailable");
204 }).pipe(Effect.provide(connectionStorageLayer));
205
206 expect(open).toHaveBeenCalledOnce();
207 }),
208 );
209
210 it.effect("reopens after a forced close and finalizes the current connection", () =>
211 Effect.gen(function* () {
212 vi.stubGlobal("window", {});
213 const makeDatabase = () =>
214 Object.assign(new EventTarget(), {
215 close: vi.fn(),
216 transaction: () => ({
217 objectStore: () => ({
218 get: () => {
219 const request = Object.assign(new EventTarget(), {
220 result: undefined,
221 error: null,
222 });
223 queueMicrotask(() => request.dispatchEvent(new Event("success")));
224 return request;
225 },
226 }),
227 }),
228 }) as unknown as IDBDatabase;
229 const first = makeDatabase();
230 const second = makeDatabase();
231 const databases = [first, second];
232 let openCount = 0;
233 const open = vi.fn(() => {
234 const request = Object.assign(new EventTarget(), {
235 result: databases[openCount++],
236 error: null,
237 });
238 queueMicrotask(() => request.dispatchEvent(new Event("success")));
239 return request;
240 });
241 vi.stubGlobal("indexedDB", { open });
242
243 yield* Effect.gen(function* () {
244 const cache = yield* Persistence.EnvironmentCacheStore;
245 const environmentId = EnvironmentId.make("env");
246 const threadId = ThreadId.make("thread");
247 expect(Option.isNone(yield* cache.loadThread(environmentId, threadId))).toBe(true);
248 expect(open).toHaveBeenCalledTimes(1);
249
250 first.dispatchEvent(new Event("close"));
251 const recovered = yield* Effect.all(
252 [cache.loadThread(environmentId, threadId), cache.loadThread(environmentId, threadId)],
253 { concurrency: 2 },
254 );
255 expect(recovered.every(Option.isNone)).toBe(true);
256 expect(open).toHaveBeenCalledTimes(2);
257 }).pipe(Effect.provide(connectionStorageLayer));
258
259 expect(first.close).not.toHaveBeenCalled();
260 expect(second.close).toHaveBeenCalledOnce();
261 }),
262 );
263});
264
265describe("IndexedDB connection closed without a close event", () => {
266 it.effect("reopens and retries the failing operation once", () =>
267 Effect.gen(function* () {
268 vi.stubGlobal("window", {});
269 const closing = Object.assign(new EventTarget(), {
270 close: vi.fn(),
271 transaction: () => {
272 // Chromium force-closed this connection; this tab never saw "close".
273 throw new DOMException("The database connection is closing.", "InvalidStateError");
274 },
275 }) as unknown as IDBDatabase;
276 const fresh = Object.assign(new EventTarget(), {
277 close: vi.fn(),
278 transaction: () => ({
279 objectStore: () => ({
280 get: () => {
281 const request = Object.assign(new EventTarget(), { result: undefined, error: null });
282 queueMicrotask(() => request.dispatchEvent(new Event("success")));
283 return request;
284 },
285 }),
286 }),
287 }) as unknown as IDBDatabase;
288 const databases = [closing, fresh];
289 let openCount = 0;
290 const open = vi.fn(() => {
291 const request = Object.assign(new EventTarget(), {
292 result: databases[openCount++],
293 error: null,
294 });
295 queueMicrotask(() => request.dispatchEvent(new Event("success")));
296 return request;
297 });
298 vi.stubGlobal("indexedDB", { open });
299
300 yield* Effect.gen(function* () {
301 const cache = yield* Persistence.EnvironmentCacheStore;
302 const loaded = yield* cache.loadThread(EnvironmentId.make("env"), ThreadId.make("thread"));
303 expect(Option.isNone(loaded)).toBe(true);
304 expect(open).toHaveBeenCalledTimes(2);
305 }).pipe(Effect.provide(connectionStorageLayer));
306 }),
307 );
308});
309
115describe("browser GitHub routing permissions", () => {310describe("browser GitHub routing permissions", () => {
116 it.effect("revokes across runtimes before storage events and resists stale catalog writes", () =>311 it.effect("revokes across runtimes before storage events and resists stale catalog writes", () =>
117 Effect.gen(function* () {312 Effect.gen(function* () {
apps/web/src/connection/storage.ts+227-83
...@@ -118,110 +118,253 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* (...@@ -118,110 +118,253 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* (
118 );118 );
119 return;119 return;
120 }120 }
121 const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);121 try {
122 request.addEventListener("upgradeneeded", () => {122 const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
123 if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) {123 request.addEventListener("upgradeneeded", () => {
124 request.result.createObjectStore(CATALOG_STORE_NAME);124 if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) {
125 }125 request.result.createObjectStore(CATALOG_STORE_NAME);
126 if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) {126 }
127 request.result.createObjectStore(SHELL_STORE_NAME);127 if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) {
128 }128 request.result.createObjectStore(SHELL_STORE_NAME);
129 if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) {129 }
130 request.result.createObjectStore(THREAD_STORE_NAME);130 if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) {
131 }131 request.result.createObjectStore(THREAD_STORE_NAME);
132 if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) {132 }
133 request.result.createObjectStore(SERVER_CONFIG_STORE_NAME);133 if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) {
134 }134 request.result.createObjectStore(SERVER_CONFIG_STORE_NAME);
135 if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) {135 }
136 request.result.createObjectStore(VCS_REFS_STORE_NAME);136 if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) {
137 }137 request.result.createObjectStore(VCS_REFS_STORE_NAME);
138 });138 }
139 request.addEventListener("error", () => {139 });
140 resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error")));140 request.addEventListener("error", () => {
141 });141 resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error")));
142 request.addEventListener("success", () => {142 });
143 resume(Effect.succeed(request.result));143 request.addEventListener("success", () => {
144 });144 resume(Effect.succeed(request.result));
145 });
146 } catch (cause) {
147 resume(Effect.fail(catalogError("open", cause)));
148 }
145 });149 });
146});150});
147151
148function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) {152interface DatabaseHandle {
153 readonly get: Effect.Effect<IDBDatabase, ConnectionTransientError>;
154 /** Forget `database` if it is still the shared connection, so the next access reopens. */
155 readonly invalidate: (database: IDBDatabase) => Effect.Effect<void>;
156}
157
158/** Share a connection until the browser closes it; the next access reopens it. */
159const makeDatabaseHandle = Effect.fn("web.connectionStorage.makeDatabaseHandle")(function* () {
160 const lock = yield* Semaphore.make(1);
161 let current: IDBDatabase | null = null;
162 const forget = (database: IDBDatabase) => {
163 if (current === database) current = null;
164 };
165 const get = Effect.suspend(() =>
166 current !== null
167 ? Effect.succeed(current)
168 : lock.withPermits(1)(
169 Effect.gen(function* () {
170 if (current !== null) return current;
171 const opened = yield* openDatabase();
172 current = opened;
173 opened.addEventListener("close", () => forget(opened));
174 // Another tab upgrading the schema waits on this connection.
175 opened.addEventListener("versionchange", () => {
176 forget(opened);
177 opened.close();
178 });
179 return opened;
180 }),
181 ),
182 );
183 const close = lock.withPermits(1)(
184 Effect.sync(() => {
185 current?.close();
186 current = null;
187 }),
188 );
189 const handle: DatabaseHandle = {
190 get,
191 invalidate: (database) => Effect.sync(() => forget(database)),
192 };
193 return { handle, close };
194});
195
196/**
197 * Runs `use` on the shared connection. A connection the browser already closed
198 * throws InvalidStateError even when no close event reached this tab, so drop
199 * it and retry once on a fresh one instead of failing every later operation.
200 */
201function withDatabase<A>(
202 database: DatabaseHandle,
203 use: (opened: IDBDatabase) => Effect.Effect<A, ConnectionTransientError>,
204) {
205 // Only a connection that opened and then failed is stale; a failing open is
206 // storage being unavailable, which a retry would not fix.
207 const closedConnection = Symbol("closedConnection");
208 const attempt = Effect.flatMap(database.get, (opened) =>
209 use(opened).pipe(
210 Effect.catchIf(
211 (error) => error.detail.includes("InvalidStateError"),
212 (error) =>
213 database
214 .invalidate(opened)
215 .pipe(Effect.andThen(Effect.fail({ [closedConnection]: error } as const))),
216 ),
217 ),
218 );
219 return attempt.pipe(
220 Effect.catchIf(
221 (error): error is { readonly [closedConnection]: ConnectionTransientError } =>
222 closedConnection in error,
223 () => Effect.flatMap(database.get, use),
224 ),
225 );
226}
227
228function readDatabaseValueOnConnection(database: IDBDatabase, storeName: string, key: IDBValidKey) {
149 return Effect.callback<unknown, ConnectionTransientError>((resume) => {229 return Effect.callback<unknown, ConnectionTransientError>((resume) => {
150 const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key);230 try {
151 request.addEventListener("error", () => {231 const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key);
152 resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error")));232 request.addEventListener("error", () => {
153 });233 resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error")));
154 request.addEventListener("success", () => {234 });
155 resume(Effect.succeed(request.result));235 request.addEventListener("success", () => {
156 });236 resume(Effect.succeed(request.result));
237 });
238 } catch (cause) {
239 resume(Effect.fail(catalogError("read", cause)));
240 }
157 }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue"));241 }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue"));
158}242}
159243
160function writeDatabaseValue(244function writeDatabaseValueOnConnection(
161 database: IDBDatabase,245 database: IDBDatabase,
162 storeName: string,246 storeName: string,
163 key: IDBValidKey,247 key: IDBValidKey,
164 value: unknown,248 value: unknown,
165) {249) {
166 return Effect.callback<void, ConnectionTransientError>((resume) => {250 return Effect.callback<void, ConnectionTransientError>((resume) => {
167 const transaction = database.transaction(storeName, "readwrite");251 try {
168 // Every failed write fires "abort". A failed commit, such as252 const transaction = database.transaction(storeName, "readwrite");
169 // QuotaExceededError, fires only "abort" and no "error".253 // Every failed write fires "abort". A failed commit, such as
170 transaction.addEventListener("abort", () => {254 // QuotaExceededError, fires only "abort" and no "error".
171 resume(255 transaction.addEventListener("abort", () => {
172 Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")),256 resume(
173 );257 Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")),
174 });258 );
175 transaction.addEventListener("complete", () => {259 });
176 resume(Effect.void);260 transaction.addEventListener("complete", () => {
177 });261 resume(Effect.void);
178 transaction.objectStore(storeName).put(value, key);262 });
263 transaction.objectStore(storeName).put(value, key);
264 } catch (cause) {
265 resume(Effect.fail(catalogError("write", cause)));
266 }
179 }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue"));267 }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue"));
180}268}
181269
182function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) {270function removeDatabaseValueOnConnection(
271 database: IDBDatabase,
272 storeName: string,
273 key: IDBValidKey,
274) {
183 return Effect.callback<void, ConnectionTransientError>((resume) => {275 return Effect.callback<void, ConnectionTransientError>((resume) => {
184 const transaction = database.transaction(storeName, "readwrite");276 try {
185 transaction.addEventListener("error", () => {277 const transaction = database.transaction(storeName, "readwrite");
186 resume(278 transaction.addEventListener("abort", () => {
187 Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error")),279 resume(
188 );280 Effect.fail(
189 });281 catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error"),
190 transaction.addEventListener("complete", () => {282 ),
191 resume(Effect.void);283 );
192 });284 });
193 transaction.objectStore(storeName).delete(key);285 transaction.addEventListener("complete", () => {
286 resume(Effect.void);
287 });
288 transaction.objectStore(storeName).delete(key);
289 } catch (cause) {
290 resume(Effect.fail(catalogError("remove", cause)));
291 }
194 }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue"));292 }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue"));
195}293}
196294
197function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) {295function removeDatabaseValuesInRangeOnConnection(
296 database: IDBDatabase,
297 storeName: string,
298 range: IDBKeyRange,
299) {
198 return Effect.callback<void, ConnectionTransientError>((resume) => {300 return Effect.callback<void, ConnectionTransientError>((resume) => {
199 const transaction = database.transaction(storeName, "readwrite");301 try {
200 transaction.addEventListener("error", () => {302 const transaction = database.transaction(storeName, "readwrite");
201 resume(303 transaction.addEventListener("abort", () => {
202 Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error")),304 resume(
203 );305 Effect.fail(
204 });306 catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error"),
205 transaction.addEventListener("complete", () => {307 ),
206 resume(Effect.void);308 );
207 });309 });
208 const request = transaction.objectStore(storeName).openCursor(range);310 transaction.addEventListener("complete", () => {
209 request.addEventListener("error", () => {311 resume(Effect.void);
210 resume(312 });
211 Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")),313 const request = transaction.objectStore(storeName).openCursor(range);
212 );314 request.addEventListener("error", () => {
213 });315 resume(
214 request.addEventListener("success", () => {316 Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")),
215 const cursor = request.result;317 );
216 if (cursor === null) {318 });
217 return;319 request.addEventListener("success", () => {
218 }320 const cursor = request.result;
219 cursor.delete();321 if (cursor === null) {
220 cursor.continue();322 return;
221 });323 }
324 try {
325 cursor.delete();
326 cursor.continue();
327 } catch (cause) {
328 resume(Effect.fail(catalogError("remove", cause)));
329 }
330 });
331 } catch (cause) {
332 resume(Effect.fail(catalogError("remove", cause)));
333 }
222 }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange"));334 }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange"));
223}335}
224336
337function readDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) {
338 return withDatabase(database, (opened) => readDatabaseValueOnConnection(opened, storeName, key));
339}
340
341function writeDatabaseValue(
342 database: DatabaseHandle,
343 storeName: string,
344 key: IDBValidKey,
345 value: unknown,
346) {
347 return withDatabase(database, (opened) =>
348 writeDatabaseValueOnConnection(opened, storeName, key, value),
349 );
350}
351
352function removeDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) {
353 return withDatabase(database, (opened) =>
354 removeDatabaseValueOnConnection(opened, storeName, key),
355 );
356}
357
358function removeDatabaseValuesInRange(
359 database: DatabaseHandle,
360 storeName: string,
361 range: IDBKeyRange,
362) {
363 return withDatabase(database, (opened) =>
364 removeDatabaseValuesInRangeOnConnection(opened, storeName, range),
365 );
366}
367
225function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) {368function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) {
226 return `${environmentId}:${threadId}`;369 return `${environmentId}:${threadId}`;
227}370}
...@@ -250,7 +393,7 @@ export interface CatalogBackend {...@@ -250,7 +393,7 @@ export interface CatalogBackend {
250 readonly quarantine?: (raw: string) => Effect.Effect<void, ConnectionTransientError>;393 readonly quarantine?: (raw: string) => Effect.Effect<void, ConnectionTransientError>;
251}394}
252395
253export function makeCatalogBackend(database: IDBDatabase): CatalogBackend {396export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend {
254 const bridge = window.desktopBridge;397 const bridge = window.desktopBridge;
255 if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) {398 if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) {
256 return {399 return {
...@@ -459,8 +602,9 @@ export function makeBrowserGitHubRoutingPermissions(...@@ -459,8 +602,9 @@ export function makeBrowserGitHubRoutingPermissions(
459602
460export const layer = Layer.effectContext(603export const layer = Layer.effectContext(
461 Effect.gen(function* () {604 Effect.gen(function* () {
462 const database = yield* Effect.acquireRelease(openDatabase(), (database) =>605 const { handle: database } = yield* Effect.acquireRelease(
463 Effect.sync(() => database.close()),606 makeDatabaseHandle(),
607 (owned) => owned.close,
464 );608 );
465 const catalog = yield* makeCatalogStore(makeCatalogBackend(database));609 const catalog = yield* makeCatalogStore(makeCatalogBackend(database));
466 const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions();610 const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions();
packages/client-runtime/src/state/threads-sync.test.ts+23-1
...@@ -108,7 +108,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -108,7 +108,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
108 readonly httpSnapshot?: ThreadSnapshotLoadResult;108 readonly httpSnapshot?: ThreadSnapshotLoadResult;
109 readonly completionMarker?: boolean;109 readonly completionMarker?: boolean;
110 readonly resumeCache?: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]>;110 readonly resumeCache?: NonNullable<Parameters<typeof makeEnvironmentThreadState>[1]>;
111 readonly loadCached?: Effect.Effect<Option.Option<OrchestrationV2ThreadDetailSnapshot>>;111 readonly loadCached?: ReturnType<Persistence.EnvironmentCacheStore["Service"]["loadThread"]>;
112 readonly saveThread?: Persistence.EnvironmentCacheStore["Service"]["saveThread"];112 readonly saveThread?: Persistence.EnvironmentCacheStore["Service"]["saveThread"];
113 readonly historyPaging?: "enabled" | "no-http" | "no-controller";113 readonly historyPaging?: "enabled" | "no-http" | "no-controller";
114 readonly historyHttpClient?: HttpClient.HttpClient;114 readonly historyHttpClient?: HttpClient.HttpClient;
...@@ -315,6 +315,28 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => {...@@ -315,6 +315,28 @@ const deleted = (sequence = 3): OrchestrationV2ThreadStreamItem => {
315};315};
316316
317describe("EnvironmentThreads", () => {317describe("EnvironmentThreads", () => {
318 it.effect("loads the server thread when its local cache read fails", () =>
319 Effect.gen(function* () {
320 const h = yield* makeHarness({
321 loadCached: Effect.fail(
322 new Persistence.ConnectionPersistenceError({
323 operation: "load-thread",
324 message: "The database connection is closing.",
325 }),
326 ),
327 httpSnapshot: {
328 _tag: "present",
329 snapshot: { snapshotSequence: 7, projection: BASE_PROJECTION },
330 },
331 });
332
333 const state = yield* awaitThreadState(h.observed, (value) => value.status === "live");
334 expect(Option.getOrThrow(state.data)).toEqual(BASE_PROJECTION);
335 expect(yield* Ref.get(h.loaderCalls)).toBe(1);
336 expect(yield* Ref.get(h.subscriptionCount)).toBe(1);
337 }),
338 );
339
318 it.effect.each(["disk", "HTTP"] as const)(340 it.effect.each(["disk", "HTTP"] as const)(
319 "does not rewrite an unchanged %s snapshot on navigation or warm return",341 "does not rewrite an unchanged %s snapshot on navigation or warm return",
320 (source) =>342 (source) =>