| ... | @@ -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 | }); |
| 147 | | 151 | |
| 148 | function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { | 152 | interface 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. */ |
| | 159 | const 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 | */ |
| | 201 | function 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 | |
| | 228 | function 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 | } |
| 159 | | 243 | |
| 160 | function writeDatabaseValue( | 244 | function 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 as | 252 | 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 | } |
| 181 | | 269 | |
| 182 | function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { | 270 | function 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 | } |
| 196 | | 294 | |
| 197 | function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) { | 295 | function 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 | } |
| 224 | | 336 | |
| | 337 | function readDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) { |
| | 338 | return withDatabase(database, (opened) => readDatabaseValueOnConnection(opened, storeName, key)); |
| | 339 | } |
| | 340 | |
| | 341 | function 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 | |
| | 352 | function removeDatabaseValue(database: DatabaseHandle, storeName: string, key: IDBValidKey) { |
| | 353 | return withDatabase(database, (opened) => |
| | 354 | removeDatabaseValueOnConnection(opened, storeName, key), |
| | 355 | ); |
| | 356 | } |
| | 357 | |
| | 358 | function removeDatabaseValuesInRange( |
| | 359 | database: DatabaseHandle, |
| | 360 | storeName: string, |
| | 361 | range: IDBKeyRange, |
| | 362 | ) { |
| | 363 | return withDatabase(database, (opened) => |
| | 364 | removeDatabaseValuesInRangeOnConnection(opened, storeName, range), |
| | 365 | ); |
| | 366 | } |
| | 367 | |
| 225 | function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { | 368 | function 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 | } |
| 252 | | 395 | |
| 253 | export function makeCatalogBackend(database: IDBDatabase): CatalogBackend { | 396 | export 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( |
| 459 | | 602 | |
| 460 | export const layer = Layer.effectContext( | 603 | export 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(); |