chore: refactoring and tests (#12468)
This commit is contained in:
69
packages/app/src/utils/scoped-cache.test.ts
Normal file
69
packages/app/src/utils/scoped-cache.test.ts
Normal file
@@ -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"])
|
||||
})
|
||||
})
|
||||
104
packages/app/src/utils/scoped-cache.ts
Normal file
104
packages/app/src/utils/scoped-cache.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user