task-87: 限制 localStorage 中任务、快照和队列数据的最大数量/字节数

新增纯 TS 有界 localStorage 包装 local-storage-limiter:maxKeys 与
maxBytes 双上限,超限驱逐最旧写入的 key(更新已存在 key 刷新新鲜度),
单条超限拒绝写入且无部分数据,存储抛错(QuotaExceededError)不污染
内部索引。重复写同 key 幂等,空 key 与不可序列化值拒绝,remove/clear
释放全部条目。8 个测试覆盖默认、批量、幂等、空、单元素、双上限驱逐、
非法输入与存储故障恢复。
This commit is contained in:
2026-08-30 22:53:08 +08:00
parent 6a470d1b39
commit 2da93414a5
2 changed files with 305 additions and 0 deletions
@@ -0,0 +1,143 @@
/**
* localStorage 有界写入(Task 87)。
*
* 限制 localStorage 中任务、快照和队列数据的最大数量与字节数:
* - maxKeys:同页面下管理的 key 总数上限,超过时驱逐最旧写入的 key
* (更新已存在 key 会刷新其新鲜度,最近使用的 key 不被驱逐);
* - maxBytes:全部条目序列化后总字节数上限,超过时同样驱逐最旧条目;
* 单条超过上限的写入被拒绝,不产生部分数据;
* - 存储抛错(如 QuotaExceededError)时写入失败且不污染内部索引;
* - 重复写同一 key 幂等(只更新内容与新鲜度,不增加条数)。
*
* 纯 TS 模块:key 为空字符串时拒绝;JSON 序列化失败时拒绝写入并保持
* 原状态;remove/clear 释放全部条目。
*/
export interface LocalStorageLimiterOptions {
/** 底层存储(window.localStorage 的窄接口,便于测试注入) */
storage: {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
removeItem: (key: string) => void
}
/** 管理的 key 总数上限,必须为正数 */
maxKeys: number
/** 全部条目序列化后总字节数上限,必须为正数 */
maxBytes: number
}
export interface LocalStorageLimiterStats {
/** 当前管理的 key 数 */
keyCount: number
/** 全部条目序列化后的总字节数 */
totalBytes: number
/** 被驱逐的条目数 */
evictedCount: number
}
const encoder = new TextEncoder()
export function createLocalStorageLimiter(options: LocalStorageLimiterOptions) {
const { storage, maxKeys, maxBytes } = options
if (!storage || typeof storage.getItem !== 'function' || typeof storage.setItem !== 'function') {
throw new Error('storage 必须提供 getItem/setItem/removeItem')
}
if (!(maxKeys > 0)) {
throw new Error('maxKeys 必须为正数: ' + maxKeys)
}
if (!(maxBytes > 0)) {
throw new Error('maxBytes 必须为正数: ' + maxBytes)
}
const order: string[] = []
let totalBytes = 0
let evictedCount = 0
function evictIfNeeded() {
while ((order.length > 0 && order.length > maxKeys) || (order.length > 0 && totalBytes > maxBytes)) {
const oldest = order.shift() as string
const raw = storage.getItem(oldest)
if (raw != null) {
totalBytes -= encoder.encode(raw).byteLength
storage.removeItem(oldest)
}
evictedCount += 1
}
}
function touch(key: string) {
const index = order.indexOf(key)
if (index >= 0) order.splice(index, 1)
order.push(key)
}
function write(key: string, value: unknown): boolean {
if (!key) return false
let raw: string
try {
raw = JSON.stringify(value)
} catch {
return false
}
if (raw == null) return false
const existing = storage.getItem(key)
const bytes = encoder.encode(raw).byteLength
if (bytes > maxBytes) return false
try {
storage.setItem(key, raw)
} catch {
return false
}
if (existing == null) {
totalBytes += bytes
touch(key)
evictIfNeeded()
} else {
totalBytes -= encoder.encode(existing).byteLength
totalBytes += bytes
touch(key)
evictIfNeeded()
}
return true
}
function read(key: string): unknown {
if (!key) return undefined
const raw = storage.getItem(key)
if (raw == null) return undefined
try {
return JSON.parse(raw)
} catch {
return undefined
}
}
function remove(key: string): boolean {
if (!key) return false
const raw = storage.getItem(key)
if (raw == null) return false
storage.removeItem(key)
totalBytes -= encoder.encode(raw).byteLength
const index = order.indexOf(key)
if (index >= 0) order.splice(index, 1)
return true
}
function clear() {
for (const key of order) storage.removeItem(key)
order.length = 0
totalBytes = 0
evictedCount = 0
}
function stats(): LocalStorageLimiterStats {
return {
keyCount: order.length,
totalBytes,
evictedCount,
}
}
return { write, read, remove, clear, stats }
}
export type LocalStorageLimiter = ReturnType<typeof createLocalStorageLimiter>