task-87: 限制 localStorage 中任务、快照和队列数据的最大数量/字节数
新增纯 TS 有界 localStorage 包装 local-storage-limiter:maxKeys 与 maxBytes 双上限,超限驱逐最旧写入的 key(更新已存在 key 刷新新鲜度), 单条超限拒绝写入且无部分数据,存储抛错(QuotaExceededError)不污染 内部索引。重复写同 key 幂等,空 key 与不可序列化值拒绝,remove/clear 释放全部条目。8 个测试覆盖默认、批量、幂等、空、单元素、双上限驱逐、 非法输入与存储故障恢复。
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user