Skip to content
← Public packages

@kentcdodds/audible

Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.

src/nas-status-cache-policy.ts

109 lines · 2.8 KB · TypeScript
/** Pure SWR policy helpers for NAS status cache (no kody:runtime). */

export const NAS_SOFT_TTL_MS = 60 * 1000
export const NAS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000

export type NasStatusCacheEntry = {
	onNas: boolean
	nasFilename: string
	checkedAt: number
	title: string
	asin?: string
}

export type NasStatusCacheStore = {
	version: 1
	byTitle: Record<string, NasStatusCacheEntry>
	byAsin: Record<string, string>
}

export type NasCacheLookup = {
	entry: NasStatusCacheEntry
	ageMs: number
	fresh: boolean
	stale: boolean
	expired: boolean
}

export function emptyNasStatusCacheStore(): NasStatusCacheStore {
	return { version: 1, byTitle: {}, byAsin: {} }
}

export function titleCacheKey(title: string): string {
	return title.trim().toLowerCase()
}

export function classifyCacheAge(
	checkedAt: number,
	now = Date.now(),
	softTtlMs = NAS_SOFT_TTL_MS,
	maxAgeMs = NAS_MAX_AGE_MS,
): { ageMs: number; fresh: boolean; stale: boolean; expired: boolean } {
	const ageMs = Math.max(0, now - checkedAt)
	const fresh = ageMs <= softTtlMs
	const expired = ageMs > maxAgeMs
	const stale = !fresh && !expired
	return { ageMs, fresh, stale, expired }
}

export function coerceNasStatusCacheStore(raw: unknown): NasStatusCacheStore {
	if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
		return emptyNasStatusCacheStore()
	}
	const obj = raw as Partial<NasStatusCacheStore>
	if (obj.version !== 1) return emptyNasStatusCacheStore()
	return {
		version: 1,
		byTitle:
			obj.byTitle && typeof obj.byTitle === 'object' && !Array.isArray(obj.byTitle)
				? (obj.byTitle as Record<string, NasStatusCacheEntry>)
				: {},
		byAsin:
			obj.byAsin && typeof obj.byAsin === 'object' && !Array.isArray(obj.byAsin)
				? (obj.byAsin as Record<string, string>)
				: {},
	}
}

export function lookupNasStatusCache(
	store: NasStatusCacheStore,
	row: { asin?: string; title: string },
	now = Date.now(),
): NasCacheLookup | null {
	const titleKey = titleCacheKey(row.title)
	let entry = store.byTitle[titleKey]
	if (!entry && row.asin) {
		const mapped = store.byAsin[row.asin.trim()]
		if (mapped) entry = store.byTitle[mapped]
	}
	if (!entry || typeof entry.checkedAt !== 'number') return null
	const age = classifyCacheAge(entry.checkedAt, now)
	if (age.expired) return null
	return { entry, ...age }
}

export function putNasStatusCacheEntry(
	store: NasStatusCacheStore,
	input: {
		title: string
		asin?: string
		nasFilename: string
		onNas: boolean
		checkedAt?: number
	},
): NasStatusCacheStore {
	const title = input.title.trim()
	if (!title) return store
	const key = titleCacheKey(title)
	const asin = input.asin?.trim() || undefined
	const entry: NasStatusCacheEntry = {
		title,
		asin,
		nasFilename: input.nasFilename || '',
		onNas: Boolean(input.onNas),
		checkedAt: input.checkedAt ?? Date.now(),
	}
	store.byTitle[key] = entry
	if (asin) store.byAsin[asin] = key
	return store
}