diff --git a/frontend-vue/src/shared/local-storage-limiter.ts b/frontend-vue/src/shared/local-storage-limiter.ts new file mode 100644 index 00000000..9e86e716 --- /dev/null +++ b/frontend-vue/src/shared/local-storage-limiter.ts @@ -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 diff --git a/frontend-vue/tests/local-storage-limiter.test.ts b/frontend-vue/tests/local-storage-limiter.test.ts new file mode 100644 index 00000000..78526b86 --- /dev/null +++ b/frontend-vue/tests/local-storage-limiter.test.ts @@ -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 = {}): 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 = {} + 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 }) +})