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>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createLocalStorageLimiter } from '../src/shared/local-storage-limiter.ts'
|
||||||
|
|
||||||
|
interface FakeStorage {
|
||||||
|
getItem: (key: string) => string | null
|
||||||
|
setItem: (key: string, value: string) => void
|
||||||
|
removeItem: (key: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeStorage(initial: Record<string, string> = {}): FakeStorage {
|
||||||
|
const map = new Map(Object.entries(initial))
|
||||||
|
return {
|
||||||
|
getItem: (key) => map.get(key) ?? null,
|
||||||
|
setItem: (key, value) => void map.set(key, value),
|
||||||
|
removeItem: (key) => void map.delete(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const bytes = (v: unknown) => encoder.encode(JSON.stringify(v)).byteLength
|
||||||
|
|
||||||
|
test('test_task_087_task_normal_default_path', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
|
||||||
|
const ok = limiter.write('brand:tasks', { ids: [1, 2, 3] })
|
||||||
|
assert.equal(ok, true)
|
||||||
|
assert.deepEqual(limiter.read('brand:tasks'), { ids: [1, 2, 3] })
|
||||||
|
const stats = limiter.stats()
|
||||||
|
assert.equal(stats.keyCount, 1)
|
||||||
|
assert.equal(stats.totalBytes, bytes({ ids: [1, 2, 3] }))
|
||||||
|
assert.equal(stats.evictedCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_087_task_normal_multiple_items', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
assert.equal(limiter.write(`k${i}`, { i }), true)
|
||||||
|
}
|
||||||
|
const stats = limiter.stats()
|
||||||
|
assert.equal(stats.keyCount, 5)
|
||||||
|
assert.deepEqual(limiter.read('k1'), { i: 1 })
|
||||||
|
assert.deepEqual(limiter.read('k5'), { i: 5 })
|
||||||
|
assert.equal(stats.totalBytes, bytes({ i: 1 }) * 5)
|
||||||
|
assert.equal(stats.evictedCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_087_task_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
|
||||||
|
limiter.write('brand:tasks', [1])
|
||||||
|
limiter.write('brand:tasks', [1])
|
||||||
|
limiter.write('brand:tasks', [1, 2, 3])
|
||||||
|
const stats = limiter.stats()
|
||||||
|
assert.equal(stats.keyCount, 1, '同 key 重复写不增加条数')
|
||||||
|
assert.equal(stats.totalBytes, bytes([1, 2, 3]))
|
||||||
|
assert.deepEqual(limiter.read('brand:tasks'), [1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_087_task_boundary_empty_input', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
|
||||||
|
assert.equal(limiter.write('', { a: 1 }), false, '空 key 拒绝')
|
||||||
|
assert.equal(limiter.read('missing'), undefined)
|
||||||
|
assert.equal(limiter.remove('missing'), false)
|
||||||
|
assert.equal(limiter.write('empty', {}), true)
|
||||||
|
assert.deepEqual(limiter.read('empty'), {})
|
||||||
|
limiter.clear()
|
||||||
|
assert.equal(limiter.stats().keyCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_087_task_boundary_single_item', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 1024 })
|
||||||
|
assert.equal(limiter.write('k', 42), true)
|
||||||
|
assert.equal(limiter.read('k'), 42)
|
||||||
|
assert.equal(limiter.stats().keyCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_087_task_boundary_limit_and_overflow', () => {
|
||||||
|
// 超过 maxKeys:驱逐最旧写入的 key,保持条数有界
|
||||||
|
const s1 = createFakeStorage()
|
||||||
|
const byKeys = createLocalStorageLimiter({ storage: s1, maxKeys: 3, maxBytes: 10_000 })
|
||||||
|
byKeys.write('a', 1)
|
||||||
|
byKeys.write('b', 2)
|
||||||
|
byKeys.write('c', 3)
|
||||||
|
byKeys.write('d', 4)
|
||||||
|
const stats1 = byKeys.stats()
|
||||||
|
assert.equal(stats1.keyCount, 3)
|
||||||
|
assert.equal(stats1.evictedCount, 1)
|
||||||
|
assert.equal(byKeys.read('a'), undefined)
|
||||||
|
assert.equal(byKeys.read('d'), 4)
|
||||||
|
// 最近写入的 key 不会被驱逐;更新已存在 key 刷新其新鲜度
|
||||||
|
byKeys.write('a', 1)
|
||||||
|
byKeys.write('e', 5)
|
||||||
|
assert.equal(byKeys.read('b'), undefined)
|
||||||
|
assert.equal(byKeys.read('a'), 1)
|
||||||
|
assert.equal(byKeys.read('e'), 5)
|
||||||
|
assert.equal(byKeys.stats().keyCount, 3)
|
||||||
|
// 单条超过 maxBytes:拒绝写入,不产生部分数据
|
||||||
|
const s2 = createFakeStorage()
|
||||||
|
const byBytes = createLocalStorageLimiter({ storage: s2, maxKeys: 10, maxBytes: 100 })
|
||||||
|
assert.equal(byBytes.write('big', { x: 'y'.repeat(200) }), false)
|
||||||
|
assert.equal(byBytes.read('big'), undefined)
|
||||||
|
assert.equal(byBytes.stats().keyCount, 0)
|
||||||
|
// 批量累积超限:驱逐最旧直到有界
|
||||||
|
const s3 = createFakeStorage()
|
||||||
|
const limited = createLocalStorageLimiter({ storage: s3, maxKeys: 10, maxBytes: 60 })
|
||||||
|
for (let i = 1; i <= 5; i++) limited.write(`k${i}`, { v: 'x'.repeat(20) })
|
||||||
|
const stats3 = limited.stats()
|
||||||
|
assert.equal(stats3.keyCount, 2)
|
||||||
|
assert.equal(stats3.evictedCount, 3)
|
||||||
|
assert.equal(limited.read('k1'), undefined)
|
||||||
|
assert.equal(limited.read('k3'), undefined)
|
||||||
|
assert.ok(limited.read('k4'))
|
||||||
|
assert.ok(limited.read('k5'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_087_task_invalid_input_rejected', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
assert.throws(() => createLocalStorageLimiter({ storage, maxKeys: 0, maxBytes: 100 }), /maxKeys 必须为正数/)
|
||||||
|
assert.throws(() => createLocalStorageLimiter({ storage, maxKeys: -1, maxBytes: 100 }), /maxKeys 必须为正数/)
|
||||||
|
assert.throws(() => createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 0 }), /maxBytes 必须为正数/)
|
||||||
|
assert.throws(() => createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: -5 }), /maxBytes 必须为正数/)
|
||||||
|
assert.throws(() => createLocalStorageLimiter({ storage: null as never, maxKeys: 10, maxBytes: 100 }), /storage 必须提供/)
|
||||||
|
const limiter = createLocalStorageLimiter({ storage, maxKeys: 10, maxBytes: 100 })
|
||||||
|
assert.equal(limiter.write('', 'x'), false)
|
||||||
|
// 不可序列化值:拒绝并保持原状态
|
||||||
|
const circular: Record<string, unknown> = {}
|
||||||
|
circular.self = circular
|
||||||
|
assert.equal(limiter.write('bad', circular), false)
|
||||||
|
assert.equal(limiter.read('bad'), undefined)
|
||||||
|
assert.equal(limiter.stats().keyCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_087_task_dependency_failure_releases_resources', () => {
|
||||||
|
const inner = createFakeStorage()
|
||||||
|
const failing: FakeStorage = {
|
||||||
|
getItem: (k) => inner.getItem(k),
|
||||||
|
setItem: (k, v) => {
|
||||||
|
if (k === 'poison') throw new Error('QuotaExceededError')
|
||||||
|
inner.setItem(k, v)
|
||||||
|
},
|
||||||
|
removeItem: (k) => inner.removeItem(k),
|
||||||
|
}
|
||||||
|
const limiter = createLocalStorageLimiter({ storage: failing, maxKeys: 10, maxBytes: 1024 })
|
||||||
|
limiter.write('a', 1)
|
||||||
|
// 存储抛错:写入失败但不污染内部状态
|
||||||
|
assert.equal(limiter.write('poison', { x: 1 }), false)
|
||||||
|
assert.equal(limiter.stats().keyCount, 1, '失败的写入不占条数')
|
||||||
|
assert.equal(limiter.read('a'), 1, '已写入数据不受影响')
|
||||||
|
// 错误可恢复:非故障 key 继续工作
|
||||||
|
assert.equal(limiter.write('b', 2), true)
|
||||||
|
assert.equal(limiter.read('b'), 2)
|
||||||
|
// remove/clear 释放全部条目
|
||||||
|
limiter.remove('a')
|
||||||
|
limiter.remove('b')
|
||||||
|
assert.equal(limiter.stats().keyCount, 0)
|
||||||
|
limiter.clear()
|
||||||
|
assert.deepEqual(limiter.stats(), { keyCount: 0, totalBytes: 0, evictedCount: 0 })
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user