| ... | ... | @@ -118,110 +118,253 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( |
| 118 | 118 | ); |
| 119 | 119 | return; |
| 120 | 120 | } |
| 121 | | const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); |
| 122 | | request.addEventListener("upgradeneeded", () => { |
| 123 | | if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { |
| 124 | | request.result.createObjectStore(CATALOG_STORE_NAME); |
| 125 | | } |
| 126 | | if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { |
| 127 | | request.result.createObjectStore(SHELL_STORE_NAME); |
| 128 | | } |
| 129 | | if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { |
| 130 | | request.result.createObjectStore(THREAD_STORE_NAME); |
| 131 | | } |
| 132 | | if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) { |
| 133 | | request.result.createObjectStore(SERVER_CONFIG_STORE_NAME); |
| 134 | | } |
| 135 | | if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { |
| 136 | | request.result.createObjectStore(VCS_REFS_STORE_NAME); |
| 137 | | } |
| 138 | | }); |
| 139 | | request.addEventListener("error", () => { |
| 140 | | resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); |
| 141 | | }); |
| 142 | | request.addEventListener("success", () => { |
| 143 | | resume(Effect.succeed(request.result)); |
| 144 | | }); |
| 121 | try { |
| 122 | const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); |
| 123 | request.addEventListener("upgradeneeded", () => { |
| 124 | if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { |
| 125 | request.result.createObjectStore(CATALOG_STORE_NAME); |
| 126 | } |
| 127 | if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { |
| 128 | request.result.createObjectStore(SHELL_STORE_NAME); |
| 129 | } |
| 130 | if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { |
| 131 | request.result.createObjectStore(THREAD_STORE_NAME); |
| 132 | } |
| 133 | if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) { |
| 134 | request.result.createObjectStore(SERVER_CONFIG_STORE_NAME); |
| 135 | } |
| 136 | if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { |
| 137 | request.result.createObjectStore(VCS_REFS_STORE_NAME); |
| 138 | } |
| 139 | }); |
| 140 | request.addEventListener("error", () => { |
| 141 | resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); |
| 142 | }); |
| 143 | request.addEventListener("success", () => { |
| 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 | 229 | return Effect.callback<unknown, ConnectionTransientError>((resume) => { |
| 150 | | const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); |
| 151 | | request.addEventListener("error", () => { |
| 152 | | resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); |
| 153 | | }); |
| 154 | | request.addEventListener("success", () => { |
| 155 | | resume(Effect.succeed(request.result)); |
| 156 | | }); |
| 230 | try { |
| 231 | const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); |
| 232 | request.addEventListener("error", () => { |
| 233 | resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); |
| 234 | }); |
| 235 | request.addEventListener("success", () => { |
| 236 | resume(Effect.succeed(request.result)); |
| 237 | }); |
| 238 | } catch (cause) { |
| 239 | resume(Effect.fail(catalogError("read", cause))); |
| 240 | } |
| 157 | 241 | }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue")); |
| 158 | 242 | } |
| 159 | 243 | |
| 160 | | function writeDatabaseValue( |
| 244 | function writeDatabaseValueOnConnection( |
| 161 | 245 | database: IDBDatabase, |
| 162 | 246 | storeName: string, |
| 163 | 247 | key: IDBValidKey, |
| 164 | 248 | value: unknown, |
| 165 | 249 | ) { |
| 166 | 250 | return Effect.callback<void, ConnectionTransientError>((resume) => { |
| 167 | | const transaction = database.transaction(storeName, "readwrite"); |
| 168 | | // Every failed write fires "abort". A failed commit, such as |
| 169 | | // QuotaExceededError, fires only "abort" and no "error". |
| 170 | | transaction.addEventListener("abort", () => { |
| 171 | | resume( |
| 172 | | Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), |
| 173 | | ); |
| 174 | | }); |
| 175 | | transaction.addEventListener("complete", () => { |
| 176 | | resume(Effect.void); |
| 177 | | }); |
| 178 | | transaction.objectStore(storeName).put(value, key); |
| 251 | try { |
| 252 | const transaction = database.transaction(storeName, "readwrite"); |
| 253 | // Every failed write fires "abort". A failed commit, such as |
| 254 | // QuotaExceededError, fires only "abort" and no "error". |
| 255 | transaction.addEventListener("abort", () => { |
| 256 | resume( |
| 257 | Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), |
| 258 | ); |
| 259 | }); |
| 260 | transaction.addEventListener("complete", () => { |
| 261 | resume(Effect.void); |
| 262 | }); |
| 263 | transaction.objectStore(storeName).put(value, key); |
| 264 | } catch (cause) { |
| 265 | resume(Effect.fail(catalogError("write", cause))); |
| 266 | } |
| 179 | 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 | 275 | return Effect.callback<void, ConnectionTransientError>((resume) => { |
| 184 | | const transaction = database.transaction(storeName, "readwrite"); |
| 185 | | transaction.addEventListener("error", () => { |
| 186 | | resume( |
| 187 | | Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error")), |
| 188 | | ); |
| 189 | | }); |
| 190 | | transaction.addEventListener("complete", () => { |
| 191 | | resume(Effect.void); |
| 192 | | }); |
| 193 | | transaction.objectStore(storeName).delete(key); |
| 276 | try { |
| 277 | const transaction = database.transaction(storeName, "readwrite"); |
| 278 | transaction.addEventListener("abort", () => { |
| 279 | resume( |
| 280 | Effect.fail( |
| 281 | catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error"), |
| 282 | ), |
| 283 | ); |
| 284 | }); |
| 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 | 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 | 300 | return Effect.callback<void, ConnectionTransientError>((resume) => { |
| 199 | | const transaction = database.transaction(storeName, "readwrite"); |
| 200 | | transaction.addEventListener("error", () => { |
| 201 | | resume( |
| 202 | | Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error")), |
| 203 | | ); |
| 204 | | }); |
| 205 | | transaction.addEventListener("complete", () => { |
| 206 | | resume(Effect.void); |
| 207 | | }); |
| 208 | | const request = transaction.objectStore(storeName).openCursor(range); |
| 209 | | request.addEventListener("error", () => { |
| 210 | | resume( |
| 211 | | Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")), |
| 212 | | ); |
| 213 | | }); |
| 214 | | request.addEventListener("success", () => { |
| 215 | | const cursor = request.result; |
| 216 | | if (cursor === null) { |
| 217 | | return; |
| 218 | | } |
| 219 | | cursor.delete(); |
| 220 | | cursor.continue(); |
| 221 | | }); |
| 301 | try { |
| 302 | const transaction = database.transaction(storeName, "readwrite"); |
| 303 | transaction.addEventListener("abort", () => { |
| 304 | resume( |
| 305 | Effect.fail( |
| 306 | catalogError("remove", transaction.error ?? "Unknown IndexedDB cursor error"), |
| 307 | ), |
| 308 | ); |
| 309 | }); |
| 310 | transaction.addEventListener("complete", () => { |
| 311 | resume(Effect.void); |
| 312 | }); |
| 313 | const request = transaction.objectStore(storeName).openCursor(range); |
| 314 | request.addEventListener("error", () => { |
| 315 | resume( |
| 316 | Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")), |
| 317 | ); |
| 318 | }); |
| 319 | request.addEventListener("success", () => { |
| 320 | const cursor = request.result; |
| 321 | if (cursor === null) { |
| 322 | return; |
| 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 | 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 | 368 | function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { |
| 226 | 369 | return `${environmentId}:${threadId}`; |
| 227 | 370 | } |
| ... | ... | @@ -250,7 +393,7 @@ export interface CatalogBackend { |
| 250 | 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 | 397 | const bridge = window.desktopBridge; |
| 255 | 398 | if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) { |
| 256 | 399 | return { |
| ... | ... | @@ -459,8 +602,9 @@ export function makeBrowserGitHubRoutingPermissions( |
| 459 | 602 | |
| 460 | 603 | export const layer = Layer.effectContext( |
| 461 | 604 | Effect.gen(function* () { |
| 462 | | const database = yield* Effect.acquireRelease(openDatabase(), (database) => |
| 463 | | Effect.sync(() => database.close()), |
| 605 | const { handle: database } = yield* Effect.acquireRelease( |
| 606 | makeDatabaseHandle(), |
| 607 | (owned) => owned.close, |
| 464 | 608 | ); |
| 465 | 609 | const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); |
| 466 | 610 | const githubRoutingPermissions = makeBrowserGitHubRoutingPermissions(); |