summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/utils
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 09:37:49 -0600
committerGitHub <[email protected]>2026-02-06 09:37:49 -0600
commita4bc883595df9ea0f752079519081bc602408553 (patch)
tree583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app/src/utils
parentc07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff)
downloadopencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz
opencode-a4bc883595df9ea0f752079519081bc602408553.zip
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app/src/utils')
-rw-r--r--packages/app/src/utils/scoped-cache.test.ts69
-rw-r--r--packages/app/src/utils/scoped-cache.ts104
2 files changed, 173 insertions, 0 deletions
diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts
new file mode 100644
index 000000000..0c6189daf
--- /dev/null
+++ b/packages/app/src/utils/scoped-cache.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import { createScopedCache } from "./scoped-cache"
+
+describe("createScopedCache", () => {
+ test("evicts least-recently-used entry when max is reached", () => {
+ const disposed: string[] = []
+ const cache = createScopedCache((key) => ({ key }), {
+ maxEntries: 2,
+ dispose: (value) => disposed.push(value.key),
+ })
+
+ const a = cache.get("a")
+ const b = cache.get("b")
+ expect(a.key).toBe("a")
+ expect(b.key).toBe("b")
+
+ cache.get("a")
+ const c = cache.get("c")
+
+ expect(c.key).toBe("c")
+ expect(cache.peek("a")?.key).toBe("a")
+ expect(cache.peek("b")).toBeUndefined()
+ expect(cache.peek("c")?.key).toBe("c")
+ expect(disposed).toEqual(["b"])
+ })
+
+ test("disposes entries on delete and clear", () => {
+ const disposed: string[] = []
+ const cache = createScopedCache((key) => ({ key }), {
+ dispose: (value) => disposed.push(value.key),
+ })
+
+ cache.get("a")
+ cache.get("b")
+
+ const removed = cache.delete("a")
+ expect(removed?.key).toBe("a")
+ expect(cache.peek("a")).toBeUndefined()
+
+ cache.clear()
+ expect(cache.peek("b")).toBeUndefined()
+ expect(disposed).toEqual(["a", "b"])
+ })
+
+ test("expires stale entries with ttl and recreates on get", () => {
+ let clock = 0
+ let count = 0
+ const disposed: string[] = []
+ const cache = createScopedCache((key) => ({ key, count: ++count }), {
+ ttlMs: 10,
+ now: () => clock,
+ dispose: (value) => disposed.push(`${value.key}:${value.count}`),
+ })
+
+ const first = cache.get("a")
+ expect(first.count).toBe(1)
+
+ clock = 9
+ expect(cache.peek("a")?.count).toBe(1)
+
+ clock = 11
+ expect(cache.peek("a")).toBeUndefined()
+ expect(disposed).toEqual(["a:1"])
+
+ const second = cache.get("a")
+ expect(second.count).toBe(2)
+ expect(disposed).toEqual(["a:1"])
+ })
+})
diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts
new file mode 100644
index 000000000..224c363c1
--- /dev/null
+++ b/packages/app/src/utils/scoped-cache.ts
@@ -0,0 +1,104 @@
+type ScopedCacheOptions<T> = {
+ maxEntries?: number
+ ttlMs?: number
+ dispose?: (value: T, key: string) => void
+ now?: () => number
+}
+
+type Entry<T> = {
+ value: T
+ touchedAt: number
+}
+
+export function createScopedCache<T>(createValue: (key: string) => T, options: ScopedCacheOptions<T> = {}) {
+ const store = new Map<string, Entry<T>>()
+ const now = options.now ?? Date.now
+
+ const dispose = (key: string, entry: Entry<T>) => {
+ options.dispose?.(entry.value, key)
+ }
+
+ const expired = (entry: Entry<T>) => {
+ if (options.ttlMs === undefined) return false
+ return now() - entry.touchedAt >= options.ttlMs
+ }
+
+ const sweep = () => {
+ if (options.ttlMs === undefined) return
+ for (const [key, entry] of store) {
+ if (!expired(entry)) continue
+ store.delete(key)
+ dispose(key, entry)
+ }
+ }
+
+ const touch = (key: string, entry: Entry<T>) => {
+ entry.touchedAt = now()
+ store.delete(key)
+ store.set(key, entry)
+ }
+
+ const prune = () => {
+ if (options.maxEntries === undefined) return
+ while (store.size > options.maxEntries) {
+ const key = store.keys().next().value
+ if (!key) return
+ const entry = store.get(key)
+ store.delete(key)
+ if (!entry) continue
+ dispose(key, entry)
+ }
+ }
+
+ const remove = (key: string) => {
+ const entry = store.get(key)
+ if (!entry) return
+ store.delete(key)
+ dispose(key, entry)
+ return entry.value
+ }
+
+ const peek = (key: string) => {
+ sweep()
+ const entry = store.get(key)
+ if (!entry) return
+ if (!expired(entry)) return entry.value
+ store.delete(key)
+ dispose(key, entry)
+ }
+
+ const get = (key: string) => {
+ sweep()
+ const entry = store.get(key)
+ if (entry && !expired(entry)) {
+ touch(key, entry)
+ return entry.value
+ }
+ if (entry) {
+ store.delete(key)
+ dispose(key, entry)
+ }
+
+ const created = {
+ value: createValue(key),
+ touchedAt: now(),
+ }
+ store.set(key, created)
+ prune()
+ return created.value
+ }
+
+ const clear = () => {
+ for (const [key, entry] of store) {
+ dispose(key, entry)
+ }
+ store.clear()
+ }
+
+ return {
+ get,
+ peek,
+ delete: remove,
+ clear,
+ }
+}