diff --git a/packages/app/app.config.js b/packages/app/app.config.js index b2f2a53d8..fef9d0e32 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,6 +1,7 @@ const fs = require("node:fs"); const path = require("node:path"); const pkg = require("./package.json"); +const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size"); const withAndroidProfileable = require("./plugins/with-android-profileable"); const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking"); const { getNativeReleaseVersion } = require("./native-release-version"); @@ -138,6 +139,7 @@ export default { }, plugins: [ "expo-router", + [withAndroidAsyncStorageSize, 10], ...buildProfile.cameraPlugins, [ "expo-splash-screen", diff --git a/packages/app/plugins/with-android-async-storage-size.js b/packages/app/plugins/with-android-async-storage-size.js new file mode 100644 index 000000000..589058356 --- /dev/null +++ b/packages/app/plugins/with-android-async-storage-size.js @@ -0,0 +1,24 @@ +const { withGradleProperties } = require("expo/config-plugins"); + +const ASYNC_STORAGE_SIZE_PROPERTY = "AsyncStorage_db_size_in_MB"; + +function withAndroidAsyncStorageSize(config, sizeInMegabytes) { + return withGradleProperties(config, (modConfig) => { + const existingProperty = modConfig.modResults.find( + (property) => property.type === "property" && property.key === ASYNC_STORAGE_SIZE_PROPERTY, + ); + if (existingProperty?.type === "property") { + existingProperty.value = String(sizeInMegabytes); + return modConfig; + } + + modConfig.modResults.push({ + type: "property", + key: ASYNC_STORAGE_SIZE_PROPERTY, + value: String(sizeInMegabytes), + }); + return modConfig; + }); +} + +module.exports = withAndroidAsyncStorageSize; diff --git a/packages/app/src/android-async-storage-config.test.ts b/packages/app/src/android-async-storage-config.test.ts new file mode 100644 index 000000000..03091afbb --- /dev/null +++ b/packages/app/src/android-async-storage-config.test.ts @@ -0,0 +1,27 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("Android AsyncStorage configuration", () => { + it("writes a 10 MiB database limit into Gradle properties", () => { + const appDirectory = path.resolve(import.meta.dirname, ".."); + const output = execFileSync( + process.execPath, + [ + "-e", + ` + const plugin = require("./plugins/with-android-async-storage-size"); + const config = plugin({ name: "Paseo", slug: "paseo" }, 10); + const mod = config.mods.android.gradleProperties; + mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config }) + .then((result) => process.stdout.write(JSON.stringify(result.modResults))); + `, + ], + { cwd: appDirectory, encoding: "utf8" }, + ); + + expect(JSON.parse(output)).toEqual([ + { type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" }, + ]); + }); +}); diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index 2722c6874..f92e7e9bf 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -1,24 +1,144 @@ import { describe, expect, it } from "vitest"; +import { Buffer } from "buffer"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; -import { createProviderSnapshotCache } from "./provider-snapshot-cache"; +import { z } from "zod"; +import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache"; -function createStorage() { +const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:"; +const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; +const SnapshotIndexSchema = z.object({ + version: z.literal(1), + entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })), +}); + +function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) { const values = new Map(); + const stats = { getAllKeysCalls: 0 }; + type Operation = "setItem" | "removeItem" | "multiRemove"; + let failure: { operation: Operation; timing: "before" | "after"; key?: string } | null = null; + + function takeFailure(operation: Operation, timing: "before" | "after", key?: string): boolean { + if ( + failure?.operation !== operation || + failure.timing !== timing || + (failure.key !== undefined && failure.key !== key) + ) { + return false; + } + failure = null; + return true; + } + return { values, + stats, + failNext(operation: Operation, timing: "before" | "after", key?: string) { + failure = { operation, timing, key }; + }, async getItem(key: string) { return values.get(key) ?? null; }, async setItem(key: string, value: string) { + if (takeFailure("setItem", "before", key)) throw new Error("Injected setItem failure"); + if (key.startsWith(SNAPSHOT_KEY_PREFIX)) { + const bytesWithoutReplacement = snapshotBytes( + new Map([...values].filter(([storedKey]) => storedKey !== key)), + ); + if ( + bytesWithoutReplacement + + Buffer.byteLength(key, "utf8") + + Buffer.byteLength(value, "utf8") > + maxSnapshotBytes + ) { + throw new Error("Injected storage capacity failure"); + } + } values.set(key, value); + if (takeFailure("setItem", "after", key)) throw new Error("Injected setItem failure"); }, async removeItem(key: string) { + if (takeFailure("removeItem", "before", key)) throw new Error("Injected removeItem failure"); values.delete(key); + if (takeFailure("removeItem", "after", key)) throw new Error("Injected removeItem failure"); + }, + async getAllKeys() { + stats.getAllKeysCalls += 1; + return [...values.keys()]; + }, + async multiGet(keys: readonly string[]) { + return keys.map((key) => [key, values.get(key) ?? null] as const); + }, + async multiRemove(keys: readonly string[]) { + if (takeFailure("multiRemove", "before")) throw new Error("Injected multiRemove failure"); + for (const [index, key] of keys.entries()) { + values.delete(key); + if (index === 0 && takeFailure("multiRemove", "after")) { + throw new Error("Injected partial multiRemove failure"); + } + } }, }; } +function snapshotEntries(label: string): ProviderSnapshotEntry[] { + return [ + { + provider: "pi", + status: "ready", + enabled: true, + models: [ + { + provider: "pi", + id: label, + label: label.repeat(20), + thinkingOptions: [], + }, + ], + }, + ]; +} + +function snapshotBytes(values: Map): number { + return [...values] + .filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX)) + .reduce( + (total, [key, value]) => + total + Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"), + 0, + ); +} + +function expectIndexMatchesSnapshots(values: Map): void { + const storedIndex = values.get(SNAPSHOT_INDEX_KEY); + if (!storedIndex) throw new Error("Expected provider snapshot index"); + const index = SnapshotIndexSchema.parse(JSON.parse(storedIndex)); + const storedSnapshots = [...values] + .filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX)) + .map(([key, value]) => ({ + key, + bytes: Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"), + })) + .toSorted((left, right) => left.key.localeCompare(right.key)); + const indexedSnapshots = index.entries + .map(({ key, bytes }) => ({ key, bytes })) + .toSorted((left, right) => left.key.localeCompare(right.key)); + expect(indexedSnapshots).toEqual(storedSnapshots); +} + +function writeSnapshot( + cache: ProviderSnapshotCache, + input: { cwd: string; label: string; generatedAt: string }, +): Promise { + return cache.write({ + serverId: "server-1", + cwd: input.cwd, + hash: input.label, + generatedAt: input.generatedAt, + compactSnapshot: compactProviderSnapshot(snapshotEntries(input.label)), + }); +} + describe("provider snapshot cache", () => { it("round-trips compact snapshots with shared thinking option references", async () => { const thinkingOptions = [{ id: "high", label: "High", isDefault: true }]; @@ -60,6 +180,356 @@ describe("provider snapshot cache", () => { storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json"); await expect(cache.read("server-1", "/repo")).resolves.toBeNull(); - expect(storage.values.size).toBe(0); + expect([...storage.values.keys()].some((key) => key.startsWith(SNAPSHOT_KEY_PREFIX))).toBe( + false, + ); }); + + it("evicts the oldest snapshots to keep the cache within its byte budget", async () => { + const storage = createStorage(); + const maxBytes = 1_000; + const cache = createProviderSnapshotCache(storage, { maxBytes }); + + await Promise.all([ + cache.write({ + serverId: "server-1", + cwd: "/oldest", + hash: "oldest", + generatedAt: "2026-08-01T00:00:00.000Z", + compactSnapshot: compactProviderSnapshot(snapshotEntries("oldest")), + }), + cache.write({ + serverId: "server-1", + cwd: "/middle", + hash: "middle", + generatedAt: "2026-08-02T00:00:00.000Z", + compactSnapshot: compactProviderSnapshot(snapshotEntries("middle")), + }), + cache.write({ + serverId: "server-1", + cwd: "/newest", + hash: "newest", + generatedAt: "2026-08-03T00:00:00.000Z", + compactSnapshot: compactProviderSnapshot(snapshotEntries("newest")), + }), + ]); + + await expect(cache.read("server-1", "/oldest")).resolves.toBeNull(); + await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it("clears unindexed snapshots once when creating the cache index", async () => { + const storage = createStorage(); + storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy"); + const cache = createProviderSnapshotCache(storage); + + await cache.write({ + serverId: "server-1", + cwd: "/current", + hash: "current", + generatedAt: "2026-08-03T00:00:00.000Z", + compactSnapshot: compactProviderSnapshot(snapshotEntries("current")), + }); + + expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); + await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + }); + + it("clears unindexed snapshots on the first read", async () => { + const storage = createStorage(); + storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy"); + const cache = createProviderSnapshotCache(storage); + + await expect(cache.read("server-1", "/legacy")).resolves.toBeNull(); + + expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it("initializes the cache index once when the first read and write race", async () => { + const storage = createStorage(); + storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy"); + const cache = createProviderSnapshotCache(storage); + + await Promise.all([ + cache.read("server-1", "/legacy"), + writeSnapshot(cache, { + cwd: "/current", + label: "current", + generatedAt: "2026-08-02T00:00:00.000Z", + }), + ]); + + await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it("replaces an indexed snapshot without counting both versions", async () => { + const storage = createStorage(); + const maxBytes = 1_000; + const cache = createProviderSnapshotCache(storage, { maxBytes }); + + await writeSnapshot(cache, { + cwd: "/repo", + label: "first", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/repo", + label: "replacement-is-larger", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/repo")).resolves.toMatchObject({ + hash: "replacement-is-larger", + }); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expect( + [...storage.values.keys()].filter((key) => key.startsWith(SNAPSHOT_KEY_PREFIX)), + ).toHaveLength(1); + expectIndexMatchesSnapshots(storage.values); + }); + + it("removes the previous value when a replacement exceeds the entire budget", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage, { maxBytes: 1_000 }); + await writeSnapshot(cache, { + cwd: "/repo", + label: "cached", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + + const tinyCache = createProviderSnapshotCache(storage, { maxBytes: 1 }); + await writeSnapshot(tinyCache, { + cwd: "/repo", + label: "too-large", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull(); + expect(snapshotBytes(storage.values)).toBe(0); + expectIndexMatchesSnapshots(storage.values); + }); + + it("resets corrupted index state before accepting another snapshot", async () => { + const storage = createStorage(); + storage.values.set(SNAPSHOT_INDEX_KEY, "corrupt"); + storage.values.set(`${SNAPSHOT_KEY_PREFIX}legacy`, "legacy"); + const cache = createProviderSnapshotCache(storage); + + await writeSnapshot(cache, { + cwd: "/current", + label: "current", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false); + await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it("measures UTF-8 bytes rather than JavaScript string length", async () => { + const stagingStorage = createStorage(); + const stagingCache = createProviderSnapshotCache(stagingStorage); + await writeSnapshot(stagingCache, { + cwd: "/emoji", + label: "🧨".repeat(100), + generatedAt: "2026-08-02T00:00:00.000Z", + }); + const storedSnapshot = [...stagingStorage.values].find(([key]) => + key.startsWith(SNAPSHOT_KEY_PREFIX), + ); + if (!storedSnapshot) throw new Error("Expected staged provider snapshot"); + const [key, value] = storedSnapshot; + const codeUnitBytes = key.length + value.length; + const utf8Bytes = Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"); + expect(utf8Bytes).toBeGreaterThan(codeUnitBytes); + + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage, { maxBytes: codeUnitBytes }); + await writeSnapshot(cache, { + cwd: "/emoji", + label: "🧨".repeat(100), + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/emoji")).resolves.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + }); + + it("keeps hundreds of concurrent writes bounded without rescanning snapshots", async () => { + const storage = createStorage(); + const maxBytes = 8_000; + const cache = createProviderSnapshotCache(storage, { maxBytes }); + const writes = Array.from({ length: 250 }, (_, index) => + writeSnapshot(cache, { + cwd: `/repo-${index}`, + label: `snapshot-${index}-${"x".repeat(index % 40)}`, + generatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + }), + ); + + await Promise.all(writes); + + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it.each(["before", "after"] as const)( + "recovers when a snapshot write fails %s its side effect", + async (timing) => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); + await writeSnapshot(cache, { + cwd: "/stable", + label: "stable", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`; + storage.failNext("setItem", timing, failedKey); + + await writeSnapshot(cache, { + cwd: "/failed", + label: "failed", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); + await writeSnapshot(restarted, { + cwd: "/next", + label: "next", + generatedAt: "2026-08-03T00:00:00.000Z", + }); + await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull(); + await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000); + expectIndexMatchesSnapshots(storage.values); + }, + ); + + it.each(["before", "after"] as const)( + "reconciles snapshot records when the derived index write fails %s its side effect", + async (timing) => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); + await writeSnapshot(cache, { + cwd: "/stable", + label: "stable", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + storage.failNext("setItem", timing, SNAPSHOT_INDEX_KEY); + + await writeSnapshot(cache, { + cwd: "/orphan-after-index-failure", + label: "orphan", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); + await expect( + restarted.read("server-1", "/orphan-after-index-failure"), + ).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(2); + }, + ); + + it("repairs partial eviction, stale index metadata, and preserves unrelated storage", async () => { + const storage = createStorage(); + const maxBytes = 1_000; + const cache = createProviderSnapshotCache(storage, { maxBytes }); + storage.values.set("@paseo/unrelated", "keep-me"); + await writeSnapshot(cache, { + cwd: "/oldest", + label: "oldest", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/middle", + label: "middle", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + storage.failNext("multiRemove", "after"); + await writeSnapshot(cache, { + cwd: "/large-newest", + label: "newest", + generatedAt: "2026-08-03T00:00:00.000Z", + }); + storage.values.set( + SNAPSHOT_INDEX_KEY, + JSON.stringify({ + version: 1, + entries: [{ key: `${SNAPSHOT_KEY_PREFIX}phantom`, bytes: 999_999, writtenAt: "bad" }], + }), + ); + + const restarted = createProviderSnapshotCache(storage, { maxBytes }); + await writeSnapshot(restarted, { + cwd: "/final", + label: "final", + generatedAt: "2026-08-04T00:00:00.000Z", + }); + + expect(storage.values.get("@paseo/unrelated")).toBe("keep-me"); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectIndexMatchesSnapshots(storage.values); + await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull(); + }); + + it("evicts before writing when storage has no temporary headroom", async () => { + const maxBytes = 1_000; + const storage = createStorage(maxBytes); + const cache = createProviderSnapshotCache(storage, { maxBytes }); + await writeSnapshot(cache, { + cwd: "/oldest", + label: "oldest", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/middle", + label: "middle", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + await writeSnapshot(cache, { + cwd: "/newest", + label: "newest", + generatedAt: "2026-08-03T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectIndexMatchesSnapshots(storage.values); + }); + + it.each(["before", "after"] as const)( + "recovers when invalid-record cleanup fails %s deletion", + async (timing) => { + const storage = createStorage(); + const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`; + storage.values.set(key, "invalid"); + storage.failNext("multiRemove", timing); + const cache = createProviderSnapshotCache(storage); + + await expect(cache.read("server-1", "/invalid")).resolves.toBeNull(); + await writeSnapshot(cache, { + cwd: "/recovered", + label: "recovered", + generatedAt: "2026-08-03T00:00:00.000Z", + }); + + expect(storage.values.has(key)).toBe(false); + await expect(cache.read("server-1", "/recovered")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + }, + ); }); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index c7da60112..43b015661 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -1,4 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Buffer } from "buffer"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { expandProviderSnapshot, @@ -8,11 +9,21 @@ import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; const CACHE_VERSION = 1; const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; +const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; +const CACHE_INDEX_VERSION = 1; +const DEFAULT_MAX_CACHE_BYTES = 4 * 1024 * 1024; interface ProviderSnapshotStorage { getItem(key: string): Promise; setItem(key: string, value: string): Promise; removeItem(key: string): Promise; + getAllKeys(): Promise; + multiGet(keys: readonly string[]): Promise>; + multiRemove(keys: readonly string[]): Promise; +} + +interface ProviderSnapshotCacheOptions { + maxBytes?: number; } interface StoredProviderSnapshot { @@ -22,6 +33,17 @@ interface StoredProviderSnapshot { compactSnapshot: CompactProviderSnapshot; } +interface ProviderSnapshotIndexEntry { + key: string; + bytes: number; + writtenAt: string; +} + +interface ProviderSnapshotIndex { + version: typeof CACHE_INDEX_VERSION; + entries: ProviderSnapshotIndexEntry[]; +} + export interface CachedProviderSnapshot extends StoredProviderSnapshot { entries: ProviderSnapshotEntry[]; } @@ -48,6 +70,15 @@ function cacheKey(serverId: string, cwd: string | null): string { return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`; } +function storedBytes(key: string, value: string): number { + return Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"); +} + +function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number { + const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt); + return writtenAtOrder !== 0 ? writtenAtOrder : left.key.localeCompare(right.key); +} + function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { const parsed: unknown = JSON.parse(value); if (typeof parsed !== "object" || parsed === null) return null; @@ -65,60 +96,142 @@ function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | nu ) { return null; } - return { - version, - hash, - generatedAt, - compactSnapshot: compactSnapshot.data, - }; -} - -async function discardCachedSnapshot(storage: ProviderSnapshotStorage, key: string): Promise { - try { - await storage.removeItem(key); - } catch (error) { - if (!(error instanceof Error)) throw error; - } + return { version, hash, generatedAt, compactSnapshot: compactSnapshot.data }; } export function createProviderSnapshotCache( storage: ProviderSnapshotStorage, + options: ProviderSnapshotCacheOptions = {}, ): ProviderSnapshotCache { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_CACHE_BYTES; + let index: ProviderSnapshotIndex | null = null; + let operationQueue = Promise.resolve(); + + async function persistIndex(entries: ProviderSnapshotIndexEntry[]): Promise { + const nextIndex: ProviderSnapshotIndex = { version: CACHE_INDEX_VERSION, entries }; + await storage.setItem(CACHE_INDEX_KEY, JSON.stringify(nextIndex)); + index = nextIndex; + } + + async function reconcileCache(): Promise { + const keys = (await storage.getAllKeys()).filter((key) => + key.startsWith(`${CACHE_KEY_PREFIX}:`), + ); + const values = await storage.multiGet(keys); + const keysToRemove: string[] = []; + const entries: ProviderSnapshotIndexEntry[] = []; + for (const [key, value] of values) { + if (value === null) continue; + try { + const stored = parseStoredProviderSnapshot(value); + if (!stored) { + keysToRemove.push(key); + continue; + } + entries.push({ key, bytes: storedBytes(key, value), writtenAt: stored.generatedAt }); + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + keysToRemove.push(key); + } + } + + let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0); + const retainedEntries = entries.toSorted(oldestFirst); + while (totalBytes > maxBytes) { + const evicted = retainedEntries.shift(); + if (!evicted) break; + totalBytes -= evicted.bytes; + keysToRemove.push(evicted.key); + } + if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); + await persistIndex(retainedEntries); + } + + async function ensureCacheIndex(): Promise { + if (index === null) await reconcileCache(); + if (index === null) throw new Error("Provider snapshot cache reconciliation failed"); + return index; + } + + async function runSerialized(operation: () => Promise): Promise { + const result = operationQueue.then(operation); + operationQueue = result.then( + () => undefined, + () => { + index = null; + }, + ); + return result; + } + + async function writeSnapshot(input: { + serverId: string; + cwd: string | null; + hash: string; + generatedAt: string; + compactSnapshot: CompactProviderSnapshot; + }): Promise { + const currentIndex = await ensureCacheIndex(); + const key = cacheKey(input.serverId, input.cwd); + const value = JSON.stringify({ + version: CACHE_VERSION, + hash: input.hash, + generatedAt: input.generatedAt, + compactSnapshot: input.compactSnapshot, + } satisfies StoredProviderSnapshot); + const incomingBytes = storedBytes(key, value); + const canStoreIncoming = incomingBytes <= maxBytes; + const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); + let projectedBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0); + if (canStoreIncoming) projectedBytes += incomingBytes; + + const keysToRemove: string[] = []; + for (const candidate of retainedEntries.toSorted(oldestFirst)) { + if (projectedBytes <= maxBytes) break; + projectedBytes -= candidate.bytes; + keysToRemove.push(candidate.key); + } + const removedKeys = new Set(keysToRemove); + const nextEntries = retainedEntries.filter((entry) => !removedKeys.has(entry.key)); + + if (!canStoreIncoming && currentIndex.entries.some((entry) => entry.key === key)) { + keysToRemove.push(key); + } + if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); + if (canStoreIncoming) { + await storage.setItem(key, value); + nextEntries.push({ key, bytes: incomingBytes, writtenAt: input.generatedAt }); + } + await persistIndex(nextEntries); + } + return { async read(serverId, cwd) { - const key = cacheKey(serverId, cwd); - let value: string | null; try { - value = await storage.getItem(key); + return await runSerialized(async () => { + const currentIndex = await ensureCacheIndex(); + const key = cacheKey(serverId, cwd); + const value = await storage.getItem(key); + if (value === null) return null; + try { + const stored = parseStoredProviderSnapshot(value); + if (!stored) throw new SyntaxError("Invalid provider snapshot"); + return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) }; + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + await storage.removeItem(key); + await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); + return null; + } + }); } catch (error) { if (error instanceof Error) return null; throw error; } - if (value === null) return null; - try { - const stored = parseStoredProviderSnapshot(value); - if (!stored) { - await discardCachedSnapshot(storage, key); - return null; - } - return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) }; - } catch (error) { - if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) { - throw error; - } - await discardCachedSnapshot(storage, key); - return null; - } }, async write(input) { - const stored: StoredProviderSnapshot = { - version: CACHE_VERSION, - hash: input.hash, - generatedAt: input.generatedAt, - compactSnapshot: input.compactSnapshot, - }; try { - await storage.setItem(cacheKey(input.serverId, input.cwd), JSON.stringify(stored)); + await runSerialized(() => writeSnapshot(input)); } catch (error) { if (!(error instanceof Error)) throw error; }