| author | |
| committer | |
| log | 34096519121033ccf72cd43ea5350f9482df188a |
| tree | 8333ca417729caf474d3821162b71c62a5acdfe8 |
| parent | ec28eefa0d017e2b0cb65ba4269cee1adddb0e98 |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 418 insertions(+), 402 deletions(-)
apps/mobile/src/components/ProjectFavicon.tsx+1-1| ... | ... | @@ -18,7 +18,7 @@ import { |
| 18 | 18 | hasLoadedProjectFavicon, |
| 19 | 19 | markProjectFaviconFailed, |
| 20 | 20 | markProjectFaviconLoaded, |
| 21 | } from "./projectFaviconCache"; | |
| 21 | } from "../lib/projectFaviconRequests"; | |
| 22 | 22 | |
| 23 | 23 | const EMPTY_FAVICON_URL = Atom.make<string | null>(null); |
| 24 | 24 |
apps/mobile/src/components/projectFaviconCache.test.ts deleted-108| ... | ... | @@ -1,108 +0,0 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | beginProjectFaviconRequest, | |
| 5 | createProjectFaviconRequest, | |
| 6 | hasLoadedProjectFavicon, | |
| 7 | markProjectFaviconFailed, | |
| 8 | markProjectFaviconLoaded, | |
| 9 | } from "./projectFaviconCache"; | |
| 10 | ||
| 11 | describe("project favicon cache", () => { | |
| 12 | it("ignores callbacks from a superseded URL", () => { | |
| 13 | const cacheKey = "environment-1:/workspace:v1-favicon.svg"; | |
| 14 | const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; | |
| 15 | const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; | |
| 16 | ||
| 17 | const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); | |
| 18 | const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); | |
| 19 | markProjectFaviconLoaded(expiredRequest); | |
| 20 | const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); | |
| 21 | const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); | |
| 22 | ||
| 23 | expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); | |
| 24 | expect(markProjectFaviconFailed(expiredRequest)).toBe(false); | |
| 25 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); | |
| 26 | expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); | |
| 27 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); | |
| 28 | ||
| 29 | endRefreshedRequest(); | |
| 30 | endExpiredRequest(); | |
| 31 | }); | |
| 32 | ||
| 33 | it("evicts the URL that actually failed", () => { | |
| 34 | const cacheKey = "environment-1:/workspace:v2-favicon.svg"; | |
| 35 | const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; | |
| 36 | const request = createProjectFaviconRequest(cacheKey, faviconUrl); | |
| 37 | const endRequest = beginProjectFaviconRequest(request); | |
| 38 | ||
| 39 | markProjectFaviconLoaded(request); | |
| 40 | ||
| 41 | expect(markProjectFaviconFailed(request)).toBe(true); | |
| 42 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); | |
| 43 | ||
| 44 | endRequest(); | |
| 45 | }); | |
| 46 | ||
| 47 | it("does not supersede a request until the next request begins", () => { | |
| 48 | const cacheKey = "environment-1:/workspace:v3-favicon.svg"; | |
| 49 | const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; | |
| 50 | const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; | |
| 51 | const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); | |
| 52 | const endCommittedRequest = beginProjectFaviconRequest(committedRequest); | |
| 53 | ||
| 54 | createProjectFaviconRequest(cacheKey, abandonedUrl); | |
| 55 | ||
| 56 | expect(markProjectFaviconLoaded(committedRequest)).toBe(true); | |
| 57 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); | |
| 58 | ||
| 59 | endCommittedRequest(); | |
| 60 | }); | |
| 61 | ||
| 62 | it("requires a cache key before creating a URL-bearing request", () => { | |
| 63 | const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; | |
| 64 | const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; | |
| 65 | ||
| 66 | expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); | |
| 67 | expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); | |
| 68 | }); | |
| 69 | ||
| 70 | it("restores the remaining active URL when a newer request ends", () => { | |
| 71 | const cacheKey = "environment-1:/workspace:v4-favicon.svg"; | |
| 72 | const firstRequest = createProjectFaviconRequest( | |
| 73 | cacheKey, | |
| 74 | "https://environment.example/api/assets/first/v4-favicon.svg", | |
| 75 | ); | |
| 76 | const secondRequest = createProjectFaviconRequest( | |
| 77 | cacheKey, | |
| 78 | "https://environment.example/api/assets/second/v4-favicon.svg", | |
| 79 | ); | |
| 80 | const endFirstRequest = beginProjectFaviconRequest(firstRequest); | |
| 81 | const endSecondRequest = beginProjectFaviconRequest(secondRequest); | |
| 82 | ||
| 83 | expect(markProjectFaviconLoaded(firstRequest)).toBe(false); | |
| 84 | endSecondRequest(); | |
| 85 | expect(markProjectFaviconLoaded(firstRequest)).toBe(true); | |
| 86 | endFirstRequest(); | |
| 87 | expect(markProjectFaviconLoaded(firstRequest)).toBe(false); | |
| 88 | }); | |
| 89 | ||
| 90 | it("bounds remembered loaded revisions", () => { | |
| 91 | const firstCacheKey = "environment-1:/workspace:revision-0"; | |
| 92 | let lastCacheKey = firstCacheKey; | |
| 93 | ||
| 94 | for (let revision = 0; revision < 300; revision++) { | |
| 95 | lastCacheKey = `environment-1:/workspace:revision-${revision}`; | |
| 96 | const request = createProjectFaviconRequest( | |
| 97 | lastCacheKey, | |
| 98 | `https://environment.example/api/assets/revision-${revision}/favicon.svg`, | |
| 99 | ); | |
| 100 | const endRequest = beginProjectFaviconRequest(request); | |
| 101 | markProjectFaviconLoaded(request); | |
| 102 | endRequest(); | |
| 103 | } | |
| 104 | ||
| 105 | expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); | |
| 106 | expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); | |
| 107 | }); | |
| 108 | }); |
apps/mobile/src/components/projectFaviconCache.ts deleted-94| ... | ... | @@ -1,94 +0,0 @@ |
| 1 | export interface ProjectFaviconRequest { | |
| 2 | readonly cacheKey: string; | |
| 3 | readonly faviconUrl: string; | |
| 4 | } | |
| 5 | ||
| 6 | interface ActiveFaviconRequests { | |
| 7 | readonly urls: Map<string, number>; | |
| 8 | currentUrl: string; | |
| 9 | } | |
| 10 | ||
| 11 | const MAX_LOADED_FAVICONS = 256; | |
| 12 | const activeFaviconRequests = new Map<string, ActiveFaviconRequests>(); | |
| 13 | const loadedFaviconKeys = new Map<string, true>(); | |
| 14 | ||
| 15 | export function createProjectFaviconRequest( | |
| 16 | cacheKey: string, | |
| 17 | faviconUrl: string, | |
| 18 | ): ProjectFaviconRequest; | |
| 19 | export function createProjectFaviconRequest( | |
| 20 | cacheKey: string | null, | |
| 21 | faviconUrl: string | null, | |
| 22 | ): ProjectFaviconRequest | null; | |
| 23 | export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { | |
| 24 | if (!cacheKey || !faviconUrl) return null; | |
| 25 | return { cacheKey, faviconUrl }; | |
| 26 | } | |
| 27 | ||
| 28 | export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { | |
| 29 | let activeRequests = activeFaviconRequests.get(request.cacheKey); | |
| 30 | if (!activeRequests) { | |
| 31 | activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; | |
| 32 | activeFaviconRequests.set(request.cacheKey, activeRequests); | |
| 33 | } | |
| 34 | ||
| 35 | const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; | |
| 36 | activeRequests.urls.delete(request.faviconUrl); | |
| 37 | activeRequests.urls.set(request.faviconUrl, activeCount + 1); | |
| 38 | activeRequests.currentUrl = request.faviconUrl; | |
| 39 | ||
| 40 | let ended = false; | |
| 41 | return () => { | |
| 42 | if (ended) return; | |
| 43 | ended = true; | |
| 44 | ||
| 45 | const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; | |
| 46 | if (remainingCount > 0) { | |
| 47 | activeRequests.urls.set(request.faviconUrl, remainingCount); | |
| 48 | return; | |
| 49 | } | |
| 50 | ||
| 51 | activeRequests.urls.delete(request.faviconUrl); | |
| 52 | if (activeRequests.urls.size === 0) { | |
| 53 | if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { | |
| 54 | activeFaviconRequests.delete(request.cacheKey); | |
| 55 | } | |
| 56 | return; | |
| 57 | } | |
| 58 | ||
| 59 | if (activeRequests.currentUrl === request.faviconUrl) { | |
| 60 | activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; | |
| 61 | } | |
| 62 | }; | |
| 63 | } | |
| 64 | ||
| 65 | export function hasLoadedProjectFavicon(cacheKey: string | null) { | |
| 66 | return cacheKey !== null && loadedFaviconKeys.has(cacheKey); | |
| 67 | } | |
| 68 | ||
| 69 | function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { | |
| 70 | return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; | |
| 71 | } | |
| 72 | ||
| 73 | function rememberLoadedProjectFavicon(cacheKey: string) { | |
| 74 | loadedFaviconKeys.delete(cacheKey); | |
| 75 | loadedFaviconKeys.set(cacheKey, true); | |
| 76 | ||
| 77 | if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { | |
| 78 | loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { | |
| 83 | if (!isCurrentProjectFaviconRequest(request)) return false; | |
| 84 | ||
| 85 | rememberLoadedProjectFavicon(request.cacheKey); | |
| 86 | return true; | |
| 87 | } | |
| 88 | ||
| 89 | export function markProjectFaviconFailed(request: ProjectFaviconRequest) { | |
| 90 | if (!isCurrentProjectFaviconRequest(request)) return false; | |
| 91 | ||
| 92 | loadedFaviconKeys.delete(request.cacheKey); | |
| 93 | return true; | |
| 94 | } |
apps/mobile/src/connection/environment-cache-store.ts+6-3| ... | ... | @@ -15,7 +15,10 @@ import * as Option from "effect/Option"; |
| 15 | 15 | import * as Schema from "effect/Schema"; |
| 16 | 16 | |
| 17 | 17 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 18 | import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 18 | import { | |
| 19 | attachProjectFaviconDatabase, | |
| 20 | projectFaviconDatabaseCache, | |
| 21 | } from "../lib/projectFaviconDatabaseCache"; | |
| 19 | 22 | |
| 20 | 23 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; |
| 21 | 24 | // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump |
| ... | ... | @@ -130,7 +133,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 130 | 133 | decode: decodeStoredShellSnapshot, |
| 131 | 134 | select: (stored) => |
| 132 | 135 | stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), |
| 133 | }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), | |
| 136 | }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), | |
| 134 | 137 | ), |
| 135 | 138 | saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { |
| 136 | 139 | const payload = yield* encodeStoredShellSnapshot({ |
| ... | ... | @@ -241,7 +244,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 241 | 244 | .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), |
| 242 | 245 | ), |
| 243 | 246 | clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => |
| 244 | Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( | |
| 247 | Effect.promise(() => projectFaviconDatabaseCache.clearEnvironment(environmentId)).pipe( | |
| 245 | 248 | Effect.andThen(database.clearEnvironmentCache(environmentId)), |
| 246 | 249 | Effect.mapError(mapDatabaseError("clear-environment")), |
| 247 | 250 | ), |
apps/mobile/src/lib/projectFaviconCache.test.ts deleted-79| ... | ... | @@ -1,79 +0,0 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; | |
| 3 | ||
| 4 | const native = vi.hoisted(() => ({ | |
| 5 | load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ | |
| 6 | width: options.maxWidth, | |
| 7 | height: options.maxHeight, | |
| 8 | release: vi.fn(), | |
| 9 | })), | |
| 10 | write: vi.fn(async () => {}), | |
| 11 | path: vi.fn(async () => "/cache/thumbnail"), | |
| 12 | read: vi.fn(), | |
| 13 | remove: vi.fn(), | |
| 14 | })); | |
| 15 | vi.mock("expo-image", () => ({ | |
| 16 | Image: { | |
| 17 | loadAsync: native.load, | |
| 18 | writeToCacheAsync: native.write, | |
| 19 | getCachePathAsync: native.path, | |
| 20 | }, | |
| 21 | })); | |
| 22 | vi.mock("expo-file-system", () => ({ | |
| 23 | File: class { | |
| 24 | size = 24_000; | |
| 25 | base64 = native.read; | |
| 26 | delete = native.remove; | |
| 27 | }, | |
| 28 | })); | |
| 29 | ||
| 30 | import { downscaleProjectFavicon } from "./projectFaviconCache"; | |
| 31 | ||
| 32 | const png = "iVBORw0KGgoAAAAA"; | |
| 33 | const image = { url: "https://remote/icon.png" }; | |
| 34 | ||
| 35 | beforeEach(() => { | |
| 36 | vi.clearAllMocks(); | |
| 37 | native.read.mockReset().mockResolvedValue(png); | |
| 38 | native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ | |
| 39 | width: maxWidth, | |
| 40 | height: maxWidth, | |
| 41 | release: vi.fn(), | |
| 42 | })); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("mobile project icon thumbnails", () => { | |
| 46 | it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { | |
| 47 | native.read.mockResolvedValueOnce( | |
| 48 | `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, | |
| 49 | ); | |
| 50 | const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); | |
| 51 | expect(thumbnail).toBe(`data:image/png;base64,${png}`); | |
| 52 | expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); | |
| 53 | expect(native.remove).toHaveBeenCalledTimes(2); | |
| 54 | for (const call of native.load.mock.results) | |
| 55 | expect((await call.value).release).toHaveBeenCalledOnce(); | |
| 56 | }); | |
| 57 | ||
| 58 | it("releases a decoded image when its request was canceled", async () => { | |
| 59 | const controller = new AbortController(); | |
| 60 | const release = vi.fn(); | |
| 61 | native.load.mockImplementationOnce(async () => { | |
| 62 | controller.abort(); | |
| 63 | return { width: 96, height: 96, release }; | |
| 64 | }); | |
| 65 | await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); | |
| 66 | expect(release).toHaveBeenCalledOnce(); | |
| 67 | expect(native.write).not.toHaveBeenCalled(); | |
| 68 | }); | |
| 69 | ||
| 70 | it("rejects an image the native decoder did not downsize", async () => { | |
| 71 | const release = vi.fn(); | |
| 72 | native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); | |
| 73 | await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( | |
| 74 | "not resized", | |
| 75 | ); | |
| 76 | expect(native.write).not.toHaveBeenCalled(); | |
| 77 | expect(release).toHaveBeenCalledOnce(); | |
| 78 | }); | |
| 79 | }); |
apps/mobile/src/lib/projectFaviconCache.ts deleted-112| ... | ... | @@ -1,112 +0,0 @@ |
| 1 | import { | |
| 2 | createProjectFaviconCache, | |
| 3 | createProjectFaviconImageLoader, | |
| 4 | PROJECT_FAVICON_MAX_DATA_URL_LENGTH, | |
| 5 | PROJECT_FAVICON_THUMBNAIL_SIZE, | |
| 6 | type ProjectFaviconEntry, | |
| 7 | } from "@t3tools/client-runtime/project-favicon-cache"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | ||
| 10 | import * as MobileDatabase from "../persistence/mobile-database"; | |
| 11 | ||
| 12 | const CACHE_KIND = "project-favicon"; | |
| 13 | const CACHE_SCHEMA_VERSION = 1; | |
| 14 | ||
| 15 | let database: MobileDatabase.MobileDatabase["Service"] | undefined; | |
| 16 | ||
| 17 | /** | |
| 18 | * The cache is a module singleton because the favicon atom family holds it outside | |
| 19 | * any Effect runtime. Its rows live in `client_cache`, so the environment cache store | |
| 20 | * hands over the database it already owns instead of the cache re-entering the runtime. | |
| 21 | */ | |
| 22 | export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { | |
| 23 | database = service; | |
| 24 | } | |
| 25 | ||
| 26 | const runDatabase = <A, E>( | |
| 27 | use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect<A, E>, | |
| 28 | ) => | |
| 29 | database | |
| 30 | ? Effect.runPromise(use(database)) | |
| 31 | : Promise.reject(new Error("Project icon storage is not attached.")); | |
| 32 | ||
| 33 | /** | |
| 34 | * Rasterizes a bitmap that is too large to inline. The native decoder writes the | |
| 35 | * downsized frame to expo-image's disk cache, which is the only encode path it | |
| 36 | * exposes; the temporary entry is removed once its bytes are read. | |
| 37 | */ | |
| 38 | export async function downscaleProjectFavicon( | |
| 39 | image: { readonly url: string }, | |
| 40 | signal: AbortSignal, | |
| 41 | ) { | |
| 42 | const [{ Image }, { File }] = await Promise.all([ | |
| 43 | import("expo-image"), | |
| 44 | import("expo-file-system"), | |
| 45 | ]); | |
| 46 | for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { | |
| 47 | signal.throwIfAborted(); | |
| 48 | const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); | |
| 49 | const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; | |
| 50 | try { | |
| 51 | signal.throwIfAborted(); | |
| 52 | if (decoded.width > size || decoded.height > size) { | |
| 53 | throw new Error("Project icon was not resized."); | |
| 54 | } | |
| 55 | await Image.writeToCacheAsync(decoded, cacheKey); | |
| 56 | const path = await Image.getCachePathAsync(cacheKey); | |
| 57 | if (!path) throw new Error("Project icon thumbnail was not written."); | |
| 58 | const file = new File(path.startsWith("file:") ? path : `file://${path}`); | |
| 59 | try { | |
| 60 | if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; | |
| 61 | const base64 = await file.base64(); | |
| 62 | // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. | |
| 63 | const mimeType = base64.startsWith("/9j/") | |
| 64 | ? "image/jpeg" | |
| 65 | : base64.startsWith("iVBORw0KGgo") | |
| 66 | ? "image/png" | |
| 67 | : null; | |
| 68 | if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); | |
| 69 | const dataUrl = `data:${mimeType};base64,${base64}`; | |
| 70 | if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; | |
| 71 | } finally { | |
| 72 | file.delete(); | |
| 73 | } | |
| 74 | } finally { | |
| 75 | decoded.release(); | |
| 76 | } | |
| 77 | } | |
| 78 | throw new Error("Project icon thumbnail exceeds the cache limit."); | |
| 79 | } | |
| 80 | ||
| 81 | /** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ | |
| 82 | export const projectFaviconCache = createProjectFaviconCache({ | |
| 83 | storage: { | |
| 84 | list: () => | |
| 85 | runDatabase((database) => | |
| 86 | database.listCache(CACHE_KIND).pipe( | |
| 87 | Effect.map((payloads) => | |
| 88 | payloads.flatMap((payload): Array<unknown> => { | |
| 89 | try { | |
| 90 | return [JSON.parse(payload)]; | |
| 91 | } catch { | |
| 92 | return []; | |
| 93 | } | |
| 94 | }), | |
| 95 | ), | |
| 96 | ), | |
| 97 | ), | |
| 98 | put: (key, entry: ProjectFaviconEntry) => | |
| 99 | runDatabase((database) => | |
| 100 | database.saveCache( | |
| 101 | entry.environmentId, | |
| 102 | CACHE_KIND, | |
| 103 | key, | |
| 104 | CACHE_SCHEMA_VERSION, | |
| 105 | JSON.stringify(entry), | |
| 106 | ), | |
| 107 | ), | |
| 108 | remove: (key, entry) => | |
| 109 | runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), | |
| 110 | }, | |
| 111 | load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), | |
| 112 | }); |
apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts created+79| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; | |
| 3 | ||
| 4 | const native = vi.hoisted(() => ({ | |
| 5 | load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ | |
| 6 | width: options.maxWidth, | |
| 7 | height: options.maxHeight, | |
| 8 | release: vi.fn(), | |
| 9 | })), | |
| 10 | write: vi.fn(async () => {}), | |
| 11 | path: vi.fn(async () => "/cache/thumbnail"), | |
| 12 | read: vi.fn(), | |
| 13 | remove: vi.fn(), | |
| 14 | })); | |
| 15 | vi.mock("expo-image", () => ({ | |
| 16 | Image: { | |
| 17 | loadAsync: native.load, | |
| 18 | writeToCacheAsync: native.write, | |
| 19 | getCachePathAsync: native.path, | |
| 20 | }, | |
| 21 | })); | |
| 22 | vi.mock("expo-file-system", () => ({ | |
| 23 | File: class { | |
| 24 | size = 24_000; | |
| 25 | base64 = native.read; | |
| 26 | delete = native.remove; | |
| 27 | }, | |
| 28 | })); | |
| 29 | ||
| 30 | import { downscaleProjectFavicon } from "./projectFaviconDatabaseCache"; | |
| 31 | ||
| 32 | const png = "iVBORw0KGgoAAAAA"; | |
| 33 | const image = { url: "https://remote/icon.png" }; | |
| 34 | ||
| 35 | beforeEach(() => { | |
| 36 | vi.clearAllMocks(); | |
| 37 | native.read.mockReset().mockResolvedValue(png); | |
| 38 | native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ | |
| 39 | width: maxWidth, | |
| 40 | height: maxWidth, | |
| 41 | release: vi.fn(), | |
| 42 | })); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("mobile project icon thumbnails", () => { | |
| 46 | it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { | |
| 47 | native.read.mockResolvedValueOnce( | |
| 48 | `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, | |
| 49 | ); | |
| 50 | const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); | |
| 51 | expect(thumbnail).toBe(`data:image/png;base64,${png}`); | |
| 52 | expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); | |
| 53 | expect(native.remove).toHaveBeenCalledTimes(2); | |
| 54 | for (const call of native.load.mock.results) | |
| 55 | expect((await call.value).release).toHaveBeenCalledOnce(); | |
| 56 | }); | |
| 57 | ||
| 58 | it("releases a decoded image when its request was canceled", async () => { | |
| 59 | const controller = new AbortController(); | |
| 60 | const release = vi.fn(); | |
| 61 | native.load.mockImplementationOnce(async () => { | |
| 62 | controller.abort(); | |
| 63 | return { width: 96, height: 96, release }; | |
| 64 | }); | |
| 65 | await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); | |
| 66 | expect(release).toHaveBeenCalledOnce(); | |
| 67 | expect(native.write).not.toHaveBeenCalled(); | |
| 68 | }); | |
| 69 | ||
| 70 | it("rejects an image the native decoder did not downsize", async () => { | |
| 71 | const release = vi.fn(); | |
| 72 | native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); | |
| 73 | await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( | |
| 74 | "not resized", | |
| 75 | ); | |
| 76 | expect(native.write).not.toHaveBeenCalled(); | |
| 77 | expect(release).toHaveBeenCalledOnce(); | |
| 78 | }); | |
| 79 | }); |
apps/mobile/src/lib/projectFaviconDatabaseCache.ts created+117| ... | ... | @@ -0,0 +1,117 @@ |
| 1 | import { | |
| 2 | createProjectFaviconCache as createSharedProjectFaviconCache, | |
| 3 | createProjectFaviconImageLoader, | |
| 4 | PROJECT_FAVICON_MAX_DATA_URL_LENGTH, | |
| 5 | PROJECT_FAVICON_THUMBNAIL_SIZE, | |
| 6 | type ProjectFaviconEntry, | |
| 7 | } from "@t3tools/client-runtime/project-favicon-cache"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | ||
| 10 | import * as MobileDatabase from "../persistence/mobile-database"; | |
| 11 | ||
| 12 | const CACHE_KIND = "project-favicon"; | |
| 13 | const CACHE_SCHEMA_VERSION = 1; | |
| 14 | ||
| 15 | let database: MobileDatabase.MobileDatabase["Service"] | undefined; | |
| 16 | ||
| 17 | /** | |
| 18 | * The cache is a module singleton because the favicon atom family holds it outside | |
| 19 | * any Effect runtime. Its rows live in `client_cache`, so the environment cache store | |
| 20 | * hands over the database it already owns instead of the cache re-entering the runtime. | |
| 21 | */ | |
| 22 | export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { | |
| 23 | database = service; | |
| 24 | } | |
| 25 | ||
| 26 | const runDatabase = <A, E>( | |
| 27 | use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect<A, E>, | |
| 28 | ) => | |
| 29 | database | |
| 30 | ? Effect.runPromise(use(database)) | |
| 31 | : Promise.reject(new Error("Project icon storage is not attached.")); | |
| 32 | ||
| 33 | /** | |
| 34 | * Rasterizes a bitmap that is too large to inline. The native decoder writes the | |
| 35 | * downsized frame to expo-image's disk cache, which is the only encode path it | |
| 36 | * exposes; the temporary entry is removed once its bytes are read. | |
| 37 | */ | |
| 38 | export async function downscaleProjectFavicon( | |
| 39 | image: { readonly url: string }, | |
| 40 | signal: AbortSignal, | |
| 41 | ) { | |
| 42 | const [{ Image }, { File }] = await Promise.all([ | |
| 43 | import("expo-image"), | |
| 44 | import("expo-file-system"), | |
| 45 | ]); | |
| 46 | for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { | |
| 47 | signal.throwIfAborted(); | |
| 48 | const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); | |
| 49 | const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; | |
| 50 | try { | |
| 51 | signal.throwIfAborted(); | |
| 52 | if (decoded.width > size || decoded.height > size) { | |
| 53 | throw new Error("Project icon was not resized."); | |
| 54 | } | |
| 55 | await Image.writeToCacheAsync(decoded, cacheKey); | |
| 56 | const path = await Image.getCachePathAsync(cacheKey); | |
| 57 | if (!path) throw new Error("Project icon thumbnail was not written."); | |
| 58 | const file = new File(path.startsWith("file:") ? path : `file://${path}`); | |
| 59 | try { | |
| 60 | if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; | |
| 61 | const base64 = await file.base64(); | |
| 62 | // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. | |
| 63 | const mimeType = base64.startsWith("/9j/") | |
| 64 | ? "image/jpeg" | |
| 65 | : base64.startsWith("iVBORw0KGgo") | |
| 66 | ? "image/png" | |
| 67 | : null; | |
| 68 | if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); | |
| 69 | const dataUrl = `data:${mimeType};base64,${base64}`; | |
| 70 | if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; | |
| 71 | } finally { | |
| 72 | file.delete(); | |
| 73 | } | |
| 74 | } finally { | |
| 75 | decoded.release(); | |
| 76 | } | |
| 77 | } | |
| 78 | throw new Error("Project icon thumbnail exceeds the cache limit."); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * The database-backed project favicon cache. Named apart from the shared | |
| 83 | * `createProjectFaviconCache` factory it calls and from the in-flight request | |
| 84 | * registry in `projectFaviconRequests`. Rows live in `client_cache` so | |
| 85 | * Settings → Client storage counts and clears them. | |
| 86 | */ | |
| 87 | export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({ | |
| 88 | storage: { | |
| 89 | list: () => | |
| 90 | runDatabase((database) => | |
| 91 | database.listCache(CACHE_KIND).pipe( | |
| 92 | Effect.map((payloads) => | |
| 93 | payloads.flatMap((payload): Array<unknown> => { | |
| 94 | try { | |
| 95 | return [JSON.parse(payload)]; | |
| 96 | } catch { | |
| 97 | return []; | |
| 98 | } | |
| 99 | }), | |
| 100 | ), | |
| 101 | ), | |
| 102 | ), | |
| 103 | put: (key, entry: ProjectFaviconEntry) => | |
| 104 | runDatabase((database) => | |
| 105 | database.saveCache( | |
| 106 | entry.environmentId, | |
| 107 | CACHE_KIND, | |
| 108 | key, | |
| 109 | CACHE_SCHEMA_VERSION, | |
| 110 | JSON.stringify(entry), | |
| 111 | ), | |
| 112 | ), | |
| 113 | remove: (key, entry) => | |
| 114 | runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), | |
| 115 | }, | |
| 116 | load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), | |
| 117 | }); |
apps/mobile/src/lib/projectFaviconRequests.test.ts created+108| ... | ... | @@ -0,0 +1,108 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | beginProjectFaviconRequest, | |
| 5 | createProjectFaviconRequest, | |
| 6 | hasLoadedProjectFavicon, | |
| 7 | markProjectFaviconFailed, | |
| 8 | markProjectFaviconLoaded, | |
| 9 | } from "./projectFaviconRequests"; | |
| 10 | ||
| 11 | describe("project favicon requests", () => { | |
| 12 | it("ignores callbacks from a superseded URL", () => { | |
| 13 | const cacheKey = "environment-1:/workspace:v1-favicon.svg"; | |
| 14 | const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; | |
| 15 | const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; | |
| 16 | ||
| 17 | const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); | |
| 18 | const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); | |
| 19 | markProjectFaviconLoaded(expiredRequest); | |
| 20 | const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); | |
| 21 | const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); | |
| 22 | ||
| 23 | expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); | |
| 24 | expect(markProjectFaviconFailed(expiredRequest)).toBe(false); | |
| 25 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); | |
| 26 | expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); | |
| 27 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); | |
| 28 | ||
| 29 | endRefreshedRequest(); | |
| 30 | endExpiredRequest(); | |
| 31 | }); | |
| 32 | ||
| 33 | it("evicts the URL that actually failed", () => { | |
| 34 | const cacheKey = "environment-1:/workspace:v2-favicon.svg"; | |
| 35 | const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; | |
| 36 | const request = createProjectFaviconRequest(cacheKey, faviconUrl); | |
| 37 | const endRequest = beginProjectFaviconRequest(request); | |
| 38 | ||
| 39 | markProjectFaviconLoaded(request); | |
| 40 | ||
| 41 | expect(markProjectFaviconFailed(request)).toBe(true); | |
| 42 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); | |
| 43 | ||
| 44 | endRequest(); | |
| 45 | }); | |
| 46 | ||
| 47 | it("does not supersede a request until the next request begins", () => { | |
| 48 | const cacheKey = "environment-1:/workspace:v3-favicon.svg"; | |
| 49 | const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; | |
| 50 | const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; | |
| 51 | const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); | |
| 52 | const endCommittedRequest = beginProjectFaviconRequest(committedRequest); | |
| 53 | ||
| 54 | createProjectFaviconRequest(cacheKey, abandonedUrl); | |
| 55 | ||
| 56 | expect(markProjectFaviconLoaded(committedRequest)).toBe(true); | |
| 57 | expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); | |
| 58 | ||
| 59 | endCommittedRequest(); | |
| 60 | }); | |
| 61 | ||
| 62 | it("requires a cache key before creating a URL-bearing request", () => { | |
| 63 | const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; | |
| 64 | const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; | |
| 65 | ||
| 66 | expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); | |
| 67 | expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); | |
| 68 | }); | |
| 69 | ||
| 70 | it("restores the remaining active URL when a newer request ends", () => { | |
| 71 | const cacheKey = "environment-1:/workspace:v4-favicon.svg"; | |
| 72 | const firstRequest = createProjectFaviconRequest( | |
| 73 | cacheKey, | |
| 74 | "https://environment.example/api/assets/first/v4-favicon.svg", | |
| 75 | ); | |
| 76 | const secondRequest = createProjectFaviconRequest( | |
| 77 | cacheKey, | |
| 78 | "https://environment.example/api/assets/second/v4-favicon.svg", | |
| 79 | ); | |
| 80 | const endFirstRequest = beginProjectFaviconRequest(firstRequest); | |
| 81 | const endSecondRequest = beginProjectFaviconRequest(secondRequest); | |
| 82 | ||
| 83 | expect(markProjectFaviconLoaded(firstRequest)).toBe(false); | |
| 84 | endSecondRequest(); | |
| 85 | expect(markProjectFaviconLoaded(firstRequest)).toBe(true); | |
| 86 | endFirstRequest(); | |
| 87 | expect(markProjectFaviconLoaded(firstRequest)).toBe(false); | |
| 88 | }); | |
| 89 | ||
| 90 | it("bounds remembered loaded revisions", () => { | |
| 91 | const firstCacheKey = "environment-1:/workspace:revision-0"; | |
| 92 | let lastCacheKey = firstCacheKey; | |
| 93 | ||
| 94 | for (let revision = 0; revision < 300; revision++) { | |
| 95 | lastCacheKey = `environment-1:/workspace:revision-${revision}`; | |
| 96 | const request = createProjectFaviconRequest( | |
| 97 | lastCacheKey, | |
| 98 | `https://environment.example/api/assets/revision-${revision}/favicon.svg`, | |
| 99 | ); | |
| 100 | const endRequest = beginProjectFaviconRequest(request); | |
| 101 | markProjectFaviconLoaded(request); | |
| 102 | endRequest(); | |
| 103 | } | |
| 104 | ||
| 105 | expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); | |
| 106 | expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); | |
| 107 | }); | |
| 108 | }); |
apps/mobile/src/lib/projectFaviconRequests.ts created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | /** | |
| 2 | * Request bookkeeping behind `ProjectFavicon`: tracks the favicon URLs with | |
| 3 | * mounted views per cache key, keeps one of them current, and remembers which | |
| 4 | * keys have already loaded so superseded or already-shown loads settle without | |
| 5 | * churn. This is not request coalescing — each mount still issues its own | |
| 6 | * load — and it is not the cache; persistence lives in | |
| 7 | * `projectFaviconDatabaseCache`. | |
| 8 | */ | |
| 9 | export interface ProjectFaviconRequest { | |
| 10 | readonly cacheKey: string; | |
| 11 | readonly faviconUrl: string; | |
| 12 | } | |
| 13 | ||
| 14 | interface ActiveFaviconRequests { | |
| 15 | readonly urls: Map<string, number>; | |
| 16 | currentUrl: string; | |
| 17 | } | |
| 18 | ||
| 19 | const MAX_LOADED_FAVICONS = 256; | |
| 20 | const activeFaviconRequests = new Map<string, ActiveFaviconRequests>(); | |
| 21 | const loadedFaviconKeys = new Map<string, true>(); | |
| 22 | ||
| 23 | export function createProjectFaviconRequest( | |
| 24 | cacheKey: string, | |
| 25 | faviconUrl: string, | |
| 26 | ): ProjectFaviconRequest; | |
| 27 | export function createProjectFaviconRequest( | |
| 28 | cacheKey: string | null, | |
| 29 | faviconUrl: string | null, | |
| 30 | ): ProjectFaviconRequest | null; | |
| 31 | export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { | |
| 32 | if (!cacheKey || !faviconUrl) return null; | |
| 33 | return { cacheKey, faviconUrl }; | |
| 34 | } | |
| 35 | ||
| 36 | export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { | |
| 37 | let activeRequests = activeFaviconRequests.get(request.cacheKey); | |
| 38 | if (!activeRequests) { | |
| 39 | activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; | |
| 40 | activeFaviconRequests.set(request.cacheKey, activeRequests); | |
| 41 | } | |
| 42 | ||
| 43 | const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; | |
| 44 | activeRequests.urls.delete(request.faviconUrl); | |
| 45 | activeRequests.urls.set(request.faviconUrl, activeCount + 1); | |
| 46 | activeRequests.currentUrl = request.faviconUrl; | |
| 47 | ||
| 48 | let ended = false; | |
| 49 | return () => { | |
| 50 | if (ended) return; | |
| 51 | ended = true; | |
| 52 | ||
| 53 | const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; | |
| 54 | if (remainingCount > 0) { | |
| 55 | activeRequests.urls.set(request.faviconUrl, remainingCount); | |
| 56 | return; | |
| 57 | } | |
| 58 | ||
| 59 | activeRequests.urls.delete(request.faviconUrl); | |
| 60 | if (activeRequests.urls.size === 0) { | |
| 61 | if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { | |
| 62 | activeFaviconRequests.delete(request.cacheKey); | |
| 63 | } | |
| 64 | return; | |
| 65 | } | |
| 66 | ||
| 67 | if (activeRequests.currentUrl === request.faviconUrl) { | |
| 68 | activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; | |
| 69 | } | |
| 70 | }; | |
| 71 | } | |
| 72 | ||
| 73 | export function hasLoadedProjectFavicon(cacheKey: string | null) { | |
| 74 | return cacheKey !== null && loadedFaviconKeys.has(cacheKey); | |
| 75 | } | |
| 76 | ||
| 77 | function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { | |
| 78 | return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; | |
| 79 | } | |
| 80 | ||
| 81 | function rememberLoadedProjectFavicon(cacheKey: string) { | |
| 82 | loadedFaviconKeys.delete(cacheKey); | |
| 83 | loadedFaviconKeys.set(cacheKey, true); | |
| 84 | ||
| 85 | if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { | |
| 86 | loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { | |
| 91 | if (!isCurrentProjectFaviconRequest(request)) return false; | |
| 92 | ||
| 93 | rememberLoadedProjectFavicon(request.cacheKey); | |
| 94 | return true; | |
| 95 | } | |
| 96 | ||
| 97 | export function markProjectFaviconFailed(request: ProjectFaviconRequest) { | |
| 98 | if (!isCurrentProjectFaviconRequest(request)) return false; | |
| 99 | ||
| 100 | loadedFaviconKeys.delete(request.cacheKey); | |
| 101 | return true; | |
| 102 | } |
apps/mobile/src/state/assets.ts+2-2| ... | ... | @@ -16,7 +16,7 @@ import { useCallback } from "react"; |
| 16 | 16 | |
| 17 | 17 | import { environmentCatalog } from "../connection/catalog"; |
| 18 | 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | import { projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 19 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; | |
| 20 | 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; |
| 21 | 21 | import { environmentSession, usePreparedConnection } from "./session"; |
| 22 | 22 | import { useAtomQueryRunner } from "./use-atom-query-runner"; |
| ... | ... | @@ -26,7 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; |
| 26 | 26 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 27 | 27 | |
| 28 | 28 | export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ |
| 29 | imageCache: projectFaviconCache, | |
| 29 | imageCache: projectFaviconDatabaseCache, | |
| 30 | 30 | createUrl: assetEnvironment.createUrl, |
| 31 | 31 | preparedConnection: environmentSession.preparedConnectionValueAtom, |
| 32 | 32 | }); |
apps/mobile/src/state/client-cache-state.ts+3-3| ... | ... | @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | |
| 5 | 5 | import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; |
| 6 | import { projectFaviconCache } from "../lib/projectFaviconCache"; | |
| 6 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; | |
| 7 | 7 | import * as Runtime from "../lib/runtime"; |
| 8 | 8 | |
| 9 | 9 | export interface EnvironmentClientCacheSummary { |
| ... | ... | @@ -74,8 +74,8 @@ export const clearClientCacheAtom = clientCacheRuntime |
| 74 | 74 | .fn((scope: ClientCacheClearScope, get) => |
| 75 | 75 | Effect.promise(() => |
| 76 | 76 | scope.type === "all" |
| 77 | ? projectFaviconCache.clearAll() | |
| 78 | : projectFaviconCache.clearEnvironment(scope.environmentId), | |
| 77 | ? projectFaviconDatabaseCache.clearAll() | |
| 78 | : projectFaviconDatabaseCache.clearEnvironment(scope.environmentId), | |
| 79 | 79 | ).pipe( |
| 80 | 80 | Effect.andThen(MobileDatabase), |
| 81 | 81 | Effect.flatMap((database) => |