diff --git a/package-lock.json b/package-lock.json index 11721b146..04ae217fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "eslint": "^9.9.1", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.11", + "fake-indexeddb": "^6.2.5", "github-markdown-css": "^5.9.0", "globals": "^15.9.0", "highlight.js": "^11.11.1", @@ -9004,6 +9005,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 26b7ad16a..7690ae9d0 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "eslint": "^9.9.1", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.11", + "fake-indexeddb": "^6.2.5", "github-markdown-css": "^5.9.0", "globals": "^15.9.0", "highlight.js": "^11.11.1", diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index a9962bf22..d816c5329 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -193,13 +193,39 @@ export const ReadmeModal: React.FC = ({ } }, []); + const scrollRafRef = useRef(null); + const scrollFrameUsesRafRef = useRef(false); + + // 进度条读取 scrollHeight/clientHeight 会强制布局,用 rAF 合帧避免滚动时反复重排。 const handleScroll = useCallback(() => { - if (!contentRef.current) return; - const container = contentRef.current; - const { scrollTop, scrollHeight, clientHeight } = container; - const progress = scrollHeight <= clientHeight ? 0 : (scrollTop / (scrollHeight - clientHeight)) * 100; - setScrollProgress(Math.min(100, Math.max(0, progress))); - setShowBackToTop(scrollTop > 300); + if (scrollRafRef.current !== null) return; + const run = () => { + scrollRafRef.current = null; + if (!contentRef.current) return; + const container = contentRef.current; + const { scrollTop, scrollHeight, clientHeight } = container; + const progress = scrollHeight <= clientHeight ? 0 : (scrollTop / (scrollHeight - clientHeight)) * 100; + setScrollProgress(Math.min(100, Math.max(0, progress))); + setShowBackToTop(scrollTop > 300); + }; + // 记录本次调度用的 API:取消时按同一 API 取消,避免环境变化后错配 id 类型。 + if (typeof window.requestAnimationFrame === 'function') { + scrollFrameUsesRafRef.current = true; + scrollRafRef.current = window.requestAnimationFrame(run); + } else { + scrollFrameUsesRafRef.current = false; + scrollRafRef.current = window.setTimeout(run, 16) as unknown as number; + } + }, []); + + useEffect(() => () => { + if (scrollRafRef.current === null) return; + if (scrollFrameUsesRafRef.current) { + window.cancelAnimationFrame(scrollRafRef.current); + } else { + window.clearTimeout(scrollRafRef.current); + } + scrollRafRef.current = null; }, []); useEffect(() => { diff --git a/src/components/settings/DataManagementPanel.tsx b/src/components/settings/DataManagementPanel.tsx index 92886a481..63c32d0c6 100644 --- a/src/components/settings/DataManagementPanel.tsx +++ b/src/components/settings/DataManagementPanel.tsx @@ -55,10 +55,10 @@ import { Rss, } from 'lucide-react'; import { useAppStore } from '../../store/useAppStore'; +import { removePersistedSnapshot } from '../../store/persistence/storage'; import { useShallow } from 'zustand/react/shallow'; import { isThemePresetId } from '../../constants/themePresets'; import type { ThemePresetId } from '../../constants/themePresets'; -import { indexedDBStorage } from '../../services/indexedDbStorage'; import { IncludeKeysToggle } from './IncludeKeysToggle'; import type { Repository, @@ -367,9 +367,14 @@ export const DataManagementPanel: React.FC = ({ t }) = // Clear sessionStorage - only remove app-owned keys APP_SESSIONSTORAGE_KEYS.forEach((key) => sessionStorage.removeItem(key)); - // Clear IndexedDB - only remove the specific database used by this app + // Clear IndexedDB - only remove the specific database used by this app. + // 走持久化层的串行删除:除磁盘清扫外,还会在串行写链上取消待写、完成删除并 + // 重置分片判脏簿记——直接调底层 removeItem 会让随后的 store 重置写入以过期 + // 基准判脏,只补写部分分片,而 meta 仍声明全部分片。 + // 必须 await 删除真正落盘(zustand v4 的 clearStorage 不回传 Promise): + // 若链上还有排队的大写入,2 秒后的 reload 可能早于删除提交,数据复活。 try { - await indexedDBStorage.removeItem('github-stars-manager'); + await removePersistedSnapshot(useAppStore.persist.getOptions().name ?? 'github-stars-manager'); } catch (error) { console.error('Failed to clear IndexedDB', error); throw new Error('IndexedDB clear failed'); diff --git a/src/services/indexedDbStorage.ts b/src/services/indexedDbStorage.ts index 0525b5548..74e2f17fb 100644 --- a/src/services/indexedDbStorage.ts +++ b/src/services/indexedDbStorage.ts @@ -7,10 +7,17 @@ const DB_VERSION = 1; const canUseIndexedDB = () => typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined'; const withTimeout = async (promise: Promise, timeoutMs = 2000): Promise => { - return await Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error('IndexedDB timeout')), timeoutMs)), - ]); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('IndexedDB timeout')), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } }; const safeLocalStorageGet = (key: string): string | null => { @@ -39,21 +46,20 @@ const safeLocalStorageRemove = (key: string): void => { } }; -const openDb = (): Promise => { - return new Promise((resolve, reject) => { - const request = window.indexedDB.open(DB_NAME, DB_VERSION); +/** 仅对「打开数据库」设超时;事务本身由 oncomplete/onabort/onerror 保证收敛。 */ +const openDb = (): Promise => withTimeout(new Promise((resolve, reject) => { + const request = window.indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - }; + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -}; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); +}), 2000); const idbGet = async (key: string): Promise => { const db = await openDb(); @@ -65,6 +71,10 @@ const idbGet = async (key: string): Promise => { req.onsuccess = () => resolve((req.result as string | undefined) ?? null); req.onerror = () => reject(req.error); tx.oncomplete = () => db.close(); + tx.onabort = () => { + db.close(); + reject(tx.error ?? new Error('[storage] IndexedDB read transaction aborted')); + }; tx.onerror = () => { db.close(); reject(tx.error); @@ -82,6 +92,10 @@ const idbSet = async (key: string, value: string): Promise => { db.close(); resolve(); }; + tx.onabort = () => { + db.close(); + reject(tx.error ?? new Error('[storage] IndexedDB write transaction aborted')); + }; tx.onerror = () => { db.close(); reject(tx.error); @@ -99,6 +113,112 @@ const idbDelete = async (key: string): Promise => { db.close(); resolve(); }; + tx.onabort = () => { + db.close(); + reject(tx.error ?? new Error('[storage] IndexedDB delete transaction aborted')); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +}; + +/** 单事务批量写入:所有键要么一起提交,要么一起回滚(分片持久化的原子提交点)。 */ +const idbSetMany = async (entries: ReadonlyArray): Promise => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + for (const [key, value] of entries) { + store.put(value, key); + } + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onabort = () => { + db.close(); + reject(tx.error ?? new Error('[storage] IndexedDB batch transaction aborted')); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +}; + +/** 单连接 + 单 readonly 事务批量读取,避免水合阶段并发打开多个连接。 */ +const idbGetMany = async (keys: ReadonlyArray): Promise> => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const results: Array = new Array(keys.length).fill(null); + keys.forEach((key, index) => { + const req = store.get(key); + req.onsuccess = () => { + results[index] = (req.result as string | undefined) ?? null; + }; + req.onerror = () => reject(req.error); + }); + + tx.oncomplete = () => { + db.close(); + resolve(results); + }; + tx.onabort = () => { + db.close(); + reject(tx.error ?? new Error('[storage] IndexedDB batch read transaction aborted')); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +}; + +/** 只探测键是否存在(不传输值),用于廉价的 legacy 快照存在性检查。 */ +const idbHasKey = async (key: string): Promise => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const req = tx.objectStore(STORE_NAME).getKey(key); + + req.onsuccess = () => resolve(req.result !== undefined); + req.onerror = () => reject(req.error); + tx.oncomplete = () => db.close(); + tx.onabort = () => { + db.close(); + reject(tx.error ?? new Error('[storage] IndexedDB key probe aborted')); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +}; + +/** 删除精确键及其全部派生键(`${name}#` 前缀:分片与 meta)。 */ +const idbDeleteWithDerivedKeys = async (name: string): Promise => { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + store.delete(name); + if (typeof IDBKeyRange !== 'undefined') { + store.delete(IDBKeyRange.bound(`${name}#`, `${name}#\uffff`)); + } + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onabort = () => { + db.close(); + reject(tx.error ?? new Error('[storage] IndexedDB delete transaction aborted')); + }; tx.onerror = () => { db.close(); reject(tx.error); @@ -106,6 +226,210 @@ const idbDelete = async (key: string): Promise => { }); }; +const safeLocalStorageRemoveByPrefix = (prefix: string): void => { + try { + for (let i = window.localStorage.length - 1; i >= 0; i--) { + const key = window.localStorage.key(i); + if (key && key.startsWith(prefix)) { + window.localStorage.removeItem(key); + } + } + } catch { + // ignore + } +}; + +const safeLocalStorageRemoveWithDerivedKeys = (name: string): void => { + safeLocalStorageRemove(name); + safeLocalStorageRemoveByPrefix(`${name}#`); +}; + +/** + * localStorage 兜底权威标记:IndexedDB 批量写失败而回退写入成功时置位, + * 表示「这些键的最新已提交值在 localStorage,读取必须优先 localStorage」。 + * 下一次 IndexedDB 写入成功、且 localStorage 不再持有任何派生数据键时清除 + * (仍有残留就保留,防止 IndexedDB 旧值遮蔽新值)。若不标记,读取端会继续 + * 优先返回 IndexedDB 中的旧值,localStorage 里的新值永远不会生效(状态静默回滚)。 + */ +const fallbackMarkerKey = (name: string): string => `${name}#fallback`; + +const isFallbackAuthoritative = (name: string): boolean => + safeLocalStorageGet(fallbackMarkerKey(name)) !== null; + +/** + * localStorage 是否仍持有该 name 的派生数据键(权威标记自身除外)。 + * 无法检查时按「仍有」处理(fail-safe):保留标记,避免 IndexedDB 里的旧值 + * 在下一次水合遮蔽 localStorage 里的新值。 + */ +const hasFallbackDerivedKeys = (name: string): boolean => { + const marker = fallbackMarkerKey(name); + try { + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key && key !== marker && key.startsWith(`${name}#`)) return true; + } + } catch { + return true; + } + return false; +}; + +/** + * localStorage 兜底写入:按“全部成功才算提交”处理,任一键失败即回滚并抛错。 + * 回滚时恢复每个键写入前的旧值(而非简单删除)——批次中的分片键通常已有 + * 上次提交的内容,删掉会让仍引用它们的 meta 在下次水合时误判为分片缺失。 + * 成功后:置权威标记,并尽力删除对应的旧 IndexedDB 键(双保险,防止读取 + * 被旧 IndexedDB 值遮蔽)。 + */ +export const writeEntriesToFallbackStorage = async ( + name: string, + entries: ReadonlyArray, +): Promise => { + const written: Array = []; + try { + for (const [key, value] of entries) { + const previous = safeLocalStorageGet(key); + if (!safeLocalStorageSet(key, value)) { + throw new Error('[storage] localStorage fallback write failed'); + } + written.push([key, previous]); + } + } catch (error) { + const unrestorable: string[] = []; + for (let i = written.length - 1; i >= 0; i--) { + const [key, previous] = written[i]; + const restored = previous === null + ? (safeLocalStorageRemove(key), true) + : safeLocalStorageSet(key, previous); + if (!restored) { + unrestorable.push(key); + } + } + if (unrestorable.length > 0) { + console.warn('[storage] fallback rollback could not restore keys:', unrestorable); + } + throw error; + } + + if (!safeLocalStorageSet(fallbackMarkerKey(name), new Date().toISOString())) { + console.warn('[storage] failed to mark localStorage as fallback authority;'); + } + if (canUseIndexedDB()) { + for (const [key] of entries) { + try { + await idbDelete(key); + } catch (error) { + console.warn('[storage] failed to invalidate stale IndexedDB key after fallback:', key, error); + } + } + } +}; + +/** + * 在单个 IndexedDB 事务里批量写入多个键(分片持久化的提交点)。 + * - IndexedDB 可用时:一个 readwrite 事务承载全部分片与 meta,天然原子—— + * 任一分片写入失败则整批回滚,旧快照原样保留,不会出现半新半旧的撕裂状态。 + * 事务不设超时:超时无法取消已启动的事务,迟到提交与 localStorage 兜底 + * 并存会造成双写;事务本身由 oncomplete/onabort/onerror 保证收敛。 + * - IndexedDB 不可用或写入失败时:退回 localStorage,并置权威标记 + * (见 writeEntriesToFallbackStorage)。 + */ +export const setStorageEntries = async ( + name: string, + entries: ReadonlyArray, +): Promise => { + if (typeof window === 'undefined') return; + if (entries.length === 0) return; + + if (canUseIndexedDB()) { + try { + await idbSetMany(entries); + for (const [key] of entries) { + safeLocalStorageRemove(key); + } + // 仅当 localStorage 不再持有任何派生数据键时才清除权威标记:分片写入只 + // 提交脏分片,早前兜底写入的其他分片可能仍只在 localStorage(兜底期间尽力 + // 删除旧 IDB 键也可能失败);此时清除标记会让 IDB 旧值在下次水合遮蔽新值。 + if (!hasFallbackDerivedKeys(name)) { + safeLocalStorageRemove(fallbackMarkerKey(name)); + } + return; + } catch (error) { + console.warn('[storage] IndexedDB batch set failed, fallback to localStorage:', error); + } + } + + await writeEntriesToFallbackStorage(name, entries); +}; + +/** + * 严格读取:读取失败(IndexedDB 打开失败/事务异常)时抛出,而不是静默降级—— + * 调用方必须能区分「键不存在」与「暂时读不到」,后者绝不能被当作空数据覆盖。 + * 读取优先级:localStorage 兜底权威标记置位时 localStorage 优先;否则 + * IndexedDB 优先,缺失时回退 localStorage(覆盖兜底写入后的场景)。 + */ +export const getStorageItemStrict = async (name: string, key: string): Promise => { + if (typeof window === 'undefined') return null; + if (!canUseIndexedDB()) return safeLocalStorageGet(key); + + const idbValue = await idbGet(key); + if (idbValue !== null) { + return isFallbackAuthoritative(name) ? (safeLocalStorageGet(key) ?? idbValue) : idbValue; + } + return safeLocalStorageGet(key); +}; + +/** 批量严格读取:任一读取失败即整体抛出(单连接单事务,缩小超时/失败窗口)。 */ +export const getStorageEntriesStrict = async ( + name: string, + keys: ReadonlyArray, +): Promise> => { + if (typeof window === 'undefined') return keys.map(() => null); + if (!canUseIndexedDB()) { + return keys.map((key) => safeLocalStorageGet(key)); + } + + const results = await idbGetMany(keys); + if (isFallbackAuthoritative(name)) { + return keys.map((key, index) => safeLocalStorageGet(key) ?? results[index]); + } + // 与单键严格读一致:IDB 未命中时回退 localStorage。兜底写入期间被尽力删除的 + // IDB 键在权威标记清除后仍可能缺失(之后的 IDB 批次只回写了当批脏分片), + // 不回退会把「键在 localStorage」误判为「分片缺失」。 + return keys.map((key, index) => results[index] ?? safeLocalStorageGet(key)); +}; + +/** 廉价探测旧版单键快照是否仍存在(不读取值本身)。 */ +export const hasLegacySnapshot = async (name: string): Promise => { + if (typeof window === 'undefined') return false; + if (safeLocalStorageGet(name) !== null) return true; + if (!canUseIndexedDB()) return false; + try { + return await idbHasKey(name); + } catch { + return false; + } +}; + +/** + * 仅删除旧版单键快照(不含分片/派生键)。 + * 分片迁移完成后由持久化层调用以释放旧快照占用的空间; + * 与 removeItem(连带清扫派生键)语义不同,误用会清掉刚提交的分片。 + */ +export const removeLegacySnapshot = async (name: string): Promise => { + if (typeof window === 'undefined') return; + + safeLocalStorageRemove(name); + + if (!canUseIndexedDB()) return; + + try { + await idbDelete(name); + } catch (error) { + console.warn('[storage] IndexedDB legacy snapshot remove failed:', error); + } +}; + /** * IndexedDB-backed Zustand persist storage with seamless migration: * - First read from IndexedDB @@ -113,6 +437,8 @@ const idbDelete = async (key: string): Promise => { * - Normal writes go to IndexedDB and clear any legacy localStorage snapshot. * - localStorage is only kept as the current snapshot when IndexedDB is unavailable or a write fails. * This avoids stale fallback rollbacks while preserving persistence in constrained environments. + * - removeItem also sweeps the derived sharding keys (`${name}#...`) so data-management + * wipes and test resets remove every persisted trace of the app. */ export const indexedDBStorage: StateStorage = { getItem: async (name: string): Promise => { @@ -124,13 +450,13 @@ export const indexedDBStorage: StateStorage = { } try { - const idbValue = await withTimeout(idbGet(name)); + const idbValue = await idbGet(name); if (idbValue !== null) return idbValue; // Migration path: restore existing localStorage snapshot into IndexedDB const legacyValue = safeLocalStorageGet(name); if (legacyValue !== null) { - await withTimeout(idbSet(name, legacyValue)); + await idbSet(name, legacyValue); safeLocalStorageRemove(name); console.info('[storage] migrated state from localStorage to IndexedDB'); } @@ -147,7 +473,7 @@ export const indexedDBStorage: StateStorage = { // Primary path: IndexedDB first (large data friendly) if (canUseIndexedDB()) { try { - await withTimeout(idbSet(name, value)); + await idbSet(name, value); safeLocalStorageRemove(name); return; } catch (error) { @@ -163,14 +489,12 @@ export const indexedDBStorage: StateStorage = { removeItem: async (name: string): Promise => { if (typeof window === 'undefined') return; - safeLocalStorageRemove(name); + safeLocalStorageRemoveWithDerivedKeys(name); if (!canUseIndexedDB()) return; - try { - await withTimeout(idbDelete(name)); - } catch (error) { - console.warn('[storage] IndexedDB remove failed:', error); - } + // 删除失败如实抛出:残留的分片/meta 会在 reload 后复活,「清空数据」等 + // 调用方必须感知失败,而不是把未完成的删除当作成功继续。 + await idbDeleteWithDerivedKeys(name); }, }; diff --git a/src/store/helpers/accountWorkspace.ts b/src/store/helpers/accountWorkspace.ts index 8c9283b45..9f7930880 100644 --- a/src/store/helpers/accountWorkspace.ts +++ b/src/store/helpers/accountWorkspace.ts @@ -81,6 +81,18 @@ export const accountIdKey = (user: Pick | number | null | unde return typeof id === 'number' && Number.isFinite(id) ? String(id) : null; }; +/** + * 带引用保留的类型过滤:过滤后长度不变说明所有元素都通过校验且顺序一致 + * (filter 只会按序保留子集),此时直接返回原数组引用。 + * 分片持久化按字段引用判脏——规整化若总是重建数组,每次启动都会把 + * accountWorkspaces 分片误判为已变更而全量重写。 + */ +const filterPreservingRef = (value: unknown, predicate: (item: unknown) => item is T): T[] => { + if (!Array.isArray(value)) return []; + const filtered = value.filter(predicate); + return filtered.length === value.length ? (value as T[]) : filtered; +}; + export const normalizeAccountWorkspaces = (value: unknown): Record => { if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; const empty = emptyAccountWorkspace(); @@ -98,24 +110,29 @@ export const normalizeAccountWorkspaces = (value: unknown): Record typeof item === 'number') - : [], + releaseSubscriptions: filterPreservingRef( + record.releaseSubscriptions, + (item): item is number => typeof item === 'number', + ), releaseSourceSettings: normalizeReleaseSourceSettings(record.releaseSourceSettings), - readReleases: Array.isArray(record.readReleases) - ? record.readReleases.filter((item): item is number => typeof item === 'number') - : [], + readReleases: filterPreservingRef( + record.readReleases, + (item): item is number => typeof item === 'number', + ), forks: Array.isArray(record.forks) ? record.forks : [], - readForks: Array.isArray(record.readForks) - ? record.readForks.filter((item): item is number => typeof item === 'number') - : [], + readForks: filterPreservingRef( + record.readForks, + (item): item is number => typeof item === 'number', + ), customCategories: Array.isArray(record.customCategories) ? record.customCategories : [], - hiddenDefaultCategoryIds: Array.isArray(record.hiddenDefaultCategoryIds) - ? record.hiddenDefaultCategoryIds.filter((item): item is string => typeof item === 'string') - : [], - categoryOrder: Array.isArray(record.categoryOrder) - ? record.categoryOrder.filter((item): item is string => typeof item === 'string') - : [], + hiddenDefaultCategoryIds: filterPreservingRef( + record.hiddenDefaultCategoryIds, + (item): item is string => typeof item === 'string', + ), + categoryOrder: filterPreservingRef( + record.categoryOrder, + (item): item is string => typeof item === 'string', + ), defaultCategoryOverrides: record.defaultCategoryOverrides && typeof record.defaultCategoryOverrides === 'object' && !Array.isArray(record.defaultCategoryOverrides) ? record.defaultCategoryOverrides as AccountWorkspace['defaultCategoryOverrides'] diff --git a/src/store/normalizers/persistedState.ts b/src/store/normalizers/persistedState.ts index 396d0ee97..5e9c1a26a 100644 --- a/src/store/normalizers/persistedState.ts +++ b/src/store/normalizers/persistedState.ts @@ -77,23 +77,40 @@ export const normalizePersistedState = ( const starredGists = Array.isArray(safePersisted.starredGists) ? safePersisted.starredGists : []; const releases = Array.isArray(safePersisted.releases) ? safePersisted.releases : []; - // Migration for old users: mark repos with existing releases as already synced - const migratedRepositories = repositories.map(repo => { - const hasExistingRelease = releases.some(r => r.repository?.id === repo.id); - if (hasExistingRelease && !repo.has_fetched_releases) { + // Migration for old users: mark repos with existing releases as already synced. + // 引用保留:无需回填时直接返回原数组——分片持久化按引用判脏,若 hydration + // 总是产生新数组引用,会在每次启动后触发一次全量重写。 + // 用 String(id) 建索引:历史快照里 id 可能被持久化为字符串,与旧实现的 + // `===` 宽松匹配历史数据保持等价;单次遍历建 Map,回填整体 O(R+L)。 + const latestReleaseTimeByRepoId = new Map(); + for (const release of releases) { + const repoId = release.repository?.id; + if (repoId === undefined || repoId === null) continue; + const key = String(repoId); + const publishedTime = new Date(release.published_at).getTime(); + const previous = latestReleaseTimeByRepoId.get(key); + latestReleaseTimeByRepoId.set(key, previous === undefined ? publishedTime : Math.max(previous, publishedTime)); + } + const needsReleaseBackfill = repositories.some( + repo => latestReleaseTimeByRepoId.has(String(repo.id)) && !repo.has_fetched_releases, + ); + const migratedRepositories = needsReleaseBackfill + ? repositories.map(repo => { + if (repo.has_fetched_releases) { + return repo; + } // Backfill last_release_fetch_time from the latest persisted release timestamp - const repoReleases = releases.filter(r => r.repository?.id === repo.id); - const latestReleaseTime = repoReleases.length > 0 - ? Math.max(...repoReleases.map(r => new Date(r.published_at).getTime())) - : null; + const latestReleaseTime = latestReleaseTimeByRepoId.get(String(repo.id)); + if (latestReleaseTime === undefined) { + return repo; + } return { ...repo, has_fetched_releases: true, last_release_fetch_time: repo.last_release_fetch_time || (latestReleaseTime ? new Date(latestReleaseTime).toISOString() : new Date().toISOString()) }; - } - return repo; - }); + }) + : repositories; // Default includePreRelease to true if not set (backward compatibility) const includePreRelease = safePersisted.includePreRelease !== undefined diff --git a/src/store/persistence/shards.ts b/src/store/persistence/shards.ts new file mode 100644 index 000000000..48631457d --- /dev/null +++ b/src/store/persistence/shards.ts @@ -0,0 +1,283 @@ + +/** + * 分片持久化布局(开发守则 §7 性能守则)。 + * + * 历史版本把整个 partialize 结果序列化成单个 IndexedDB 键,任何状态变更 + * (包括开关一个弹窗)都要重新 stringify 并重写全部数据(实测可达 ~144MB, + * 单次 stringify ~130ms + 写入 ~270ms,持续阻塞主线程)。 + * + * 分片原则: + * - 体积大、变更低频的重数据(repositories/gists/releases/forks/账号工作区/ + * trending 快照)各自独立分片,只在内容真正变化(引用判脏)时重写; + * - 体积小、变更高频的偏好与视图状态集中到 core 分片,单次重写仅几 KB; + * - partialize 未来新增的键由 resolveShardForField 兜底归入 core,保证 + * 任何持久化字段都不会因遗漏分片映射而丢失。 + * + * ── 硬性契约 ──────────────────────────────────────────────────────────── + * 1. 不可变更新:分片按「字段引用」判脏的前提是持久化字段永远不可变更新。 + * 对 repositories/gists/releases/forks/accountWorkspaces/trendingSnapshots + * 的任何原地修改(push/sort/索引赋值)都会因引用未变而静默不落盘。 + * 新代码必须使用不可变更新(这是 zustand 生态的既有约定)。 + * 2. 格式版本:递增 PERSISTENCE_SHARD_FORMAT 时必须同步提供 + * readPersistedSnapshot 的旧格式迁移分支;仅改常量会让旧分片不可读, + * 且 legacy 单键快照在首次迁移后已退役——结果是空启动后覆盖全部数据。 + * ─────────────────────────────────────────────────────────────────────── + */ + +export const PERSISTENCE_SHARD_FORMAT = 1; + +export type PersistenceShardName = + | 'core' + | 'repositories' + | 'gists' + | 'releases' + | 'forks' + | 'accountWorkspaces' + | 'trendingSnapshots'; + +export const PERSISTENCE_SHARD_NAMES: readonly PersistenceShardName[] = [ + 'core', + 'repositories', + 'gists', + 'releases', + 'forks', + 'accountWorkspaces', + 'trendingSnapshots', +]; + +export const PERSISTENCE_SHARD_FIELDS: Record = { + // 体积小、变更高频的偏好/视图/配置状态;单次 stringify+写入仅几 KB。 + core: [ + 'user', + 'githubToken', + 'isAuthenticated', + 'themeTokens', + 'gistSearchFilters', + 'selectedGistCategory', + 'analyzingGistIds', + 'aiConfigs', + 'activeAIConfig', + 'repositoryChatSettings', + 'embeddingConfigs', + 'activeEmbeddingConfig', + 'vectorSearchConfig', + 'vectorSearchStatus', + 'mcpConfig', + 'webdavConfigs', + 'activeWebDAVConfig', + 'lastBackup', + 'releaseSubscriptions', + 'releaseSourceSettings', + 'readReleases', + 'readForks', + 'customCategories', + 'hiddenDefaultCategoryIds', + 'categoryOrder', + 'collapsedSidebarCategoryCount', + 'categoryMatchMode', + 'defaultCategoryOverrides', + 'assetFilters', + 'repositoryCardFields', + 'lastSync', + 'theme', + 'themePreset', + 'currentView', + 'selectedCategory', + 'language', + 'translationEngine', + 'autoTranslateRepoDescription', + 'isSidebarCollapsed', + 'headerMenuConfig', + 'backendApiSecret', + 'syncMode', + 'syncModeConfigured', + 'categoryListIdMap', + 'searchFilters', + 'repositoryViewMode', + 'releaseViewMode', + 'releaseShowMode', + 'releaseLatestMode', + 'releaseSelectedFilters', + 'releaseSearchQuery', + 'releaseExpandedRepositories', + 'includePreRelease', + 'includeKeysInBackup', + 'forkViewMode', + 'forkSelectedFilters', + 'forkSearchQuery', + 'forkExpandedRepositories', + 'discoveryChannels', + 'selectedDiscoveryChannel', + 'discoveryPlatform', + 'discoveryLanguage', + 'discoverySortBy', + 'discoverySortOrder', + 'discoverySelectedTopic', + 'weeklyOnlyCollected', + 'xTweetFollows', + 'xTweetAuthRevision', + 'telegramFollows', + 'proxyConfig', + 'rpcDownloadConfig', + 'routeMode', + ], + // 以下为体积大、变更低频的重数据分片:仅在对应数组引用变化时重写。 + repositories: ['repositories'], + gists: ['gists', 'starredGists'], + releases: ['releases'], + forks: ['forks'], + // 登出时每账号快照一份完整数据,可能成为最大分片;按账号逐项判脏(见 + // accountWorkspacesUnchanged)避免 hydration 规整化产生的新对象引用触发全量重写。 + accountWorkspaces: ['accountWorkspaces'], + trendingSnapshots: ['trendingSnapshots'], +}; + +const FIELD_TO_SHARD = new Map(); +for (const [shard, fields] of Object.entries(PERSISTENCE_SHARD_FIELDS)) { + for (const field of fields) { + if (FIELD_TO_SHARD.has(field)) { + throw new Error(`[persistence] field "${field}" is assigned to more than one shard`); + } + FIELD_TO_SHARD.set(field, shard as PersistenceShardName); + } +} + +/** 未知字段兜底归入 core,保证新增持久化字段不会因遗漏映射而丢失。 */ +export const resolveShardForField = (field: string): PersistenceShardName => + FIELD_TO_SHARD.get(field) ?? 'core'; + +export const shardKeyFor = (name: string, shard: PersistenceShardName): string => + `${name}#shard:${shard}`; + +export const metaKeyFor = (name: string): string => `${name}#meta`; + +/** 分片清单,写入 meta 键;读取时以它为准拼装完整快照。 */ +export interface ShardMeta { + format: number; + version: number; + shards: PersistenceShardName[]; + savedAt: string; +} + +/** 单个分片的持久化载荷。 */ +export interface ShardPayload { + format: number; + version: number; + state: Record; +} + +/** 按 partialize 输出构建每个分片的字段视图(保留原引用,不拷贝)。 */ +export const buildShardViews = ( + state: Record, +): Record> => { + const views = {} as Record>; + for (const shard of PERSISTENCE_SHARD_NAMES) { + views[shard] = {}; + } + for (const [field, value] of Object.entries(state)) { + views[resolveShardForField(field)][field] = value; + } + return views; +}; + +// 账号工作区:重数组按引用比较;规整化会重建的小对象按内容比较, +// 避免 hydration 后的新引用被误判为真实变更。 +// 维护约定:AccountWorkspace 的每个字段必须恰好出现在其中一个列表里 +// (storage.test.ts 有覆盖断言),否则新字段的内容变化会被判脏逻辑漏掉。 +export const WORKSPACE_REFERENCE_FIELDS = [ + 'repositories', + 'gists', + 'starredGists', + 'releases', + 'forks', + 'customCategories', + 'categoryOrder', + 'readReleases', + 'readForks', + 'releaseSubscriptions', + 'hiddenDefaultCategoryIds', +] as const; + +export const WORKSPACE_VALUE_FIELDS = [ + 'lastSync', + 'selectedGistCategory', + 'releaseSourceSettings', + 'defaultCategoryOverrides', + 'categoryListIdMap', + 'syncMode', + 'syncModeConfigured', +] as const; + +const smallValueEqual = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +}; + +const accountWorkspacesUnchanged = (prev: unknown, next: unknown): boolean => { + if (Object.is(prev, next)) return true; + if (!prev || !next || typeof prev !== 'object' || typeof next !== 'object') return false; + if (Array.isArray(prev) || Array.isArray(next)) return false; + const prevRecord = prev as Record; + const nextRecord = next as Record; + const prevKeys = Object.keys(prevRecord); + if (prevKeys.length !== Object.keys(nextRecord).length) return false; + for (const accountId of prevKeys) { + if (!Object.prototype.hasOwnProperty.call(nextRecord, accountId)) return false; + const prevWorkspace = prevRecord[accountId]; + const nextWorkspace = nextRecord[accountId]; + if (Object.is(prevWorkspace, nextWorkspace)) continue; + if (!prevWorkspace || !nextWorkspace + || typeof prevWorkspace !== 'object' || typeof nextWorkspace !== 'object') { + return false; + } + const a = prevWorkspace as Record; + const b = nextWorkspace as Record; + // 未知字段数量不一致视为已变更,避免漏写历史快照里的额外字段。 + if (Object.keys(a).length !== Object.keys(b).length) return false; + for (const field of WORKSPACE_REFERENCE_FIELDS) { + if (!Object.is(a[field], b[field])) return false; + } + for (const field of WORKSPACE_VALUE_FIELDS) { + if (!smallValueEqual(a[field], b[field])) return false; + } + } + return true; +}; + +/** + * 找出相对上次成功写入发生变化的分片。 + * + * - 重数据分片按字段引用判脏(zustand 不可变更新保证未变字段引用不变); + * - core 分片按内容判脏:partialize 每次调用都会重建若干小对象/数组 + * (searchFilters、proxyConfig、Array.from(Set) 等),按引用会让 core + * 恒判脏;core 体积只有几 KB,JSON 内容比较成本可忽略。 + */ +export const findDirtyShards = ( + views: Record>, + lastWritten: Record> | null, +): PersistenceShardName[] => { + if (!lastWritten) return [...PERSISTENCE_SHARD_NAMES]; + return PERSISTENCE_SHARD_NAMES.filter((shard) => { + const next = views[shard]; + const prev = lastWritten[shard]; + if (!prev) return true; + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); + for (const key of keys) { + if (shard === 'accountWorkspaces' && key === 'accountWorkspaces') { + if (!accountWorkspacesUnchanged(prev[key], next[key])) return true; + continue; + } + if (shard === 'core') { + if (!smallValueEqual(prev[key], next[key])) return true; + continue; + } + if (!Object.is(prev[key], next[key])) return true; + } + return false; + }); +}; diff --git a/src/store/persistence/storage.test.ts b/src/store/persistence/storage.test.ts new file mode 100644 index 000000000..f87306f31 --- /dev/null +++ b/src/store/persistence/storage.test.ts @@ -0,0 +1,789 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StorageValue } from 'zustand/middleware'; +import { + getStorageEntriesStrict, + getStorageItemStrict, + indexedDBStorage, + setStorageEntries, + writeEntriesToFallbackStorage, +} from '../../services/indexedDbStorage'; +import { appPersistenceOptions } from './options'; +import { createInitialState } from '../initialState'; +import { flushPendingPersistSnapshot } from './storage'; +import { debouncedPersistStorage, removePersistedSnapshot } from './storage'; +import { normalizeAccountWorkspaces } from '../helpers/accountWorkspace'; +import { + PERSISTENCE_SHARD_FIELDS, + PERSISTENCE_SHARD_NAMES, + WORKSPACE_REFERENCE_FIELDS, + WORKSPACE_VALUE_FIELDS, + buildShardViews, + findDirtyShards, + metaKeyFor, + shardKeyFor, +} from './shards'; +import { normalizeTrendingSnapshots } from '../../utils/trendingSnapshots'; + +// 提交点屏障:在真实存储提交入口(setStorageEntries)拦停写入,用于让并发 +// 测试确定性地与「写入 A 仍在提交中」重叠——setTimeout 等待无法保证这一点。 +const commitGate = vi.hoisted(() => { + let hold = false; + let release: () => void = () => undefined; + let markReached: (() => void) | null = null; + let reached: Promise = Promise.resolve(); + return { + arm: () => { + hold = true; + reached = new Promise((resolve) => { + markReached = resolve; + }); + }, + waitReached: (): Promise => reached, + release: () => { + hold = false; + release(); + }, + reset: () => { + hold = false; + release(); + }, + wrap: async (run: () => Promise): Promise => { + if (hold) { + markReached?.(); + await new Promise((resolve) => { + release = resolve; + }); + } + return run(); + }, + }; +}); + +vi.mock('../../services/indexedDbStorage', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // 真实签名是 (name, entries)——转发参数必须一一对应,否则会静默改写调用语义。 + setStorageEntries: (name: string, entries: ReadonlyArray) => + commitGate.wrap(() => actual.setStorageEntries(name, entries)), + }; +}); + +const KEY = 'github-stars-manager'; + +const tick = (ms = 20): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const buildState = (): Record => ({ + // 以 createInitialState 为底:partialize 会直读全部持久化字段(如 + // state.gistSearchFilters.sortBy),手写的稀疏对象会让它抛错。 + ...(createInitialState() as unknown as Record), + theme: 'dark', + language: 'zh', + repositories: [{ id: 1, name: 'repo-one' }], + lastSync: '2026-01-01T00:00:00.000Z', + gists: [{ id: 'gist-1' }], + starredGists: [], + releases: [{ id: 101 }], + forks: [], + // 规整化后的工作区包含全部 18 个字段:键数量不同的工作区会被判脏守卫拦下 + accountWorkspaces: { + '1': { + repositories: [{ id: 1, name: 'repo-one' }], + lastSync: '2026-01-01T00:00:00.000Z', + gists: [], + starredGists: [], + releases: [], + forks: [], + customCategories: [], + categoryOrder: ['cat-a'], + readReleases: [1, 2], + readForks: [], + releaseSubscriptions: [1], + hiddenDefaultCategoryIds: ['cat-b'], + selectedGistCategory: 'all', + releaseSourceSettings: { enabledSourceIds: ['starred-release-subscription'], watchCustomReleaseRepos: [], customReleaseRepos: [] }, + defaultCategoryOverrides: {}, + categoryListIdMap: {}, + syncMode: 'stars', + syncModeConfigured: false, + }, + }, + // 快照必须至少含一条 entry(空 entries 的快照会被规整化丢弃) + trendingSnapshots: [ + { + period: 'daily', + platform: 'All', + capturedAt: new Date(Date.now() - 24 * 3600 * 1000).toISOString(), + entries: [{ repositoryFullName: 'a/b', rank: 1, stars: 9 }], + }, + ], + customCategories: [{ id: 'cat-1', name: 'Work' }], +}); + +const readRaw = async (key: string): Promise => indexedDBStorage.getItem(key); +const readStrict = async (key: string): Promise => getStorageItemStrict(KEY, key); +const readMeta = async (): Promise<{ savedAt: string; version: number; shards: string[] } | null> => { + const raw = await readRaw(metaKeyFor(KEY)); + return raw ? JSON.parse(raw) : null; +}; +const readShardState = async (shard: Parameters[1]): Promise> => { + const raw = await readRaw(shardKeyFor(KEY, shard)); + expect(raw).not.toBeNull(); + return JSON.parse(raw as string).state as Record; +}; + +/** 把磁盘分片改写成缩进 JSON,用于检测「哪些分片被后续启动重写」。 */ +const markShardsOnDisk = async (): Promise => { + for (const shard of PERSISTENCE_SHARD_NAMES) { + const rawJson = await readRaw(shardKeyFor(KEY, shard)); + if (rawJson) { + await indexedDBStorage.setItem(shardKeyFor(KEY, shard), JSON.stringify(JSON.parse(rawJson), null, 2)); + } + } +}; + +const rewrittenShards = async (): Promise => { + const rewritten: string[] = []; + for (const shard of PERSISTENCE_SHARD_NAMES) { + const rawJson = await readRaw(shardKeyFor(KEY, shard)); + if (rawJson && !rawJson.includes('\n "')) rewritten.push(shard); + } + return rewritten; +}; + +beforeEach(async () => { + vi.restoreAllMocks(); + commitGate.reset(); + debouncedPersistStorage.removeItem(KEY); + await tick(); +}); + +describe('persistence shard layout', () => { + it('assigns fields to the expected shards and falls back to core for unknown fields', () => { + const views = buildShardViews({ + theme: 'dark', + repositories: [{ id: 1 }], + lastSync: '2026-01-01T00:00:00.000Z', + gists: [], + starredGists: [], + releases: [], + forks: [], + accountWorkspaces: {}, + trendingSnapshots: [], + someFutureField: 'kept-in-core', + }); + + expect(Object.keys(views.core)).toEqual(['theme', 'lastSync', 'someFutureField']); + expect(views.repositories).toEqual({ repositories: [{ id: 1 }] }); + expect(Object.keys(views.gists)).toEqual(['gists', 'starredGists']); + expect(Object.keys(views.releases)).toEqual(['releases']); + expect(Object.keys(views.forks)).toEqual(['forks']); + expect(Object.keys(views.accountWorkspaces)).toEqual(['accountWorkspaces']); + expect(Object.keys(views.trendingSnapshots)).toEqual(['trendingSnapshots']); + }); + + it('covers every persisted partialize key with exactly one shard', () => { + const partialize = appPersistenceOptions.partialize!; + const persistedKeys = Object.keys(partialize(createInitialState() as never)); + expect(persistedKeys.length).toBeGreaterThan(0); + + const covered = PERSISTENCE_SHARD_NAMES.flatMap((shard) => [...PERSISTENCE_SHARD_FIELDS[shard]]); + expect(new Set(covered).size).toBe(covered.length); + for (const key of persistedKeys) { + expect(covered, `persisted key "${key}" must be assigned to a shard`).toContain(key); + } + }); + + it('covers every normalized workspace field with exactly one comparison list', () => { + // AccountWorkspace 新增字段时必须归入 REFERENCE 或 VALUE 列表之一, + // 否则该字段的内容变化会被判脏逻辑漏掉(漏写)。 + const normalized = normalizeAccountWorkspaces({ '1': {} })['1']; + const covered = new Set([...WORKSPACE_REFERENCE_FIELDS, ...WORKSPACE_VALUE_FIELDS]); + for (const field of Object.keys(normalized)) { + expect(covered.has(field), `workspace field "${field}" must be in a comparison list`).toBe(true); + } + for (const field of covered) { + expect(field in normalized, `comparison list field "${field}" must exist on the workspace`).toBe(true); + } + }); +}); + +describe('persistence shard dirty detection', () => { + const viewsOf = (state: Record) => buildShardViews(state); + + it('treats every shard as dirty without a baseline and none as dirty with identical references', () => { + const state = buildState(); + const views = viewsOf(state); + + expect(findDirtyShards(views, null)).toEqual([...PERSISTENCE_SHARD_NAMES]); + expect(findDirtyShards(views, views)).toEqual([]); + }); + + it('marks only the shard whose field reference changed', () => { + const state = buildState(); + const baseline = viewsOf(state); + const next = viewsOf({ ...state, theme: 'light' }); + + expect(findDirtyShards(next, baseline)).toEqual(['core']); + }); + + it('ignores hydration-normalized accountWorkspaces with equal content and detects real changes', () => { + // 穿过真实的 normalizeAccountWorkspaces:规整化不得因重建数组引用而把 + // 未变更的工作区判为已变更(否则每次启动都会全量重写该分片)。 + const state = buildState(); + const baseline = viewsOf(state); + const normalized = normalizeAccountWorkspaces(state.accountWorkspaces); + + const untouched = findDirtyShards(viewsOf({ ...state, accountWorkspaces: normalized }), baseline); + expect(untouched).toEqual([]); + + // 真实变更(logout 快照替换了重数组引用)必须判脏 + const changedWorkspaces = { + '1': { + ...(normalized as unknown as Record>)['1'], + repositories: [{ id: 2, name: 'repo-two' }], + }, + }; + expect(findDirtyShards(viewsOf({ ...state, accountWorkspaces: changedWorkspaces }), baseline)) + .toEqual(['accountWorkspaces']); + }); + + it('keeps trendingSnapshots reference when normalization is content-equal', () => { + const state = buildState(); + const snapshots = state.trendingSnapshots as unknown as Array>; + expect(normalizeTrendingSnapshots(snapshots as never)).toBe(snapshots as never); + + // 内容真正过期/非法时仍然重建 + const stale = [{ ...snapshots[0], capturedAt: '2000-01-01T00:00:00.000Z' }]; + expect(normalizeTrendingSnapshots(stale)).toEqual([]); + }); + + it('compares core shard fields by content because partialize rebuilds small objects', () => { + // 真实 partialize 每次都会重建 searchFilters/proxyConfig/派生数组等 core 字段; + // core 按内容判脏才能让「无变化的写入」真正跳过。 + const state = buildState(); + const first = appPersistenceOptions.partialize!(state as never); + const second = appPersistenceOptions.partialize!(state as never); + const baseline = viewsOf(first as Record); + + expect(findDirtyShards(viewsOf(second as Record), baseline)).toEqual([]); + + const changed = appPersistenceOptions.partialize!({ ...state, theme: 'light' } as never); + expect(findDirtyShards(viewsOf(changed as Record), baseline)).toEqual(['core']); + }); +}); + +describe('sharded persist storage', () => { + it('hydrates from the legacy single-key snapshot, commits shards atomically and retires the legacy key', async () => { + const legacyValue: StorageValue = { state: buildState(), version: 16 }; + await indexedDBStorage.setItem(KEY, JSON.stringify(legacyValue)); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + // legacy 快照经 JSON 往返(Set→对象、undefined 丢弃),期望侧做同样归一化 + expect(hydrated).toEqual(JSON.parse(JSON.stringify(legacyValue))); + + const nextValue: StorageValue = { state: buildState(), version: 16 }; + debouncedPersistStorage.setItem(KEY, nextValue); + await flushPendingPersistSnapshot(); + + // 全部分片 + meta 已提交 + const meta = await readMeta(); + expect(meta?.version).toBe(16); + expect(meta?.shards).toEqual([...PERSISTENCE_SHARD_NAMES]); + const repositoriesShard = await readShardState('repositories'); + expect(repositoriesShard.repositories).toEqual([{ id: 1, name: 'repo-one' }]); + const coreShard = await readShardState('core'); + expect(coreShard.theme).toBe('dark'); + expect(coreShard.lastSync).toBe('2026-01-01T00:00:00.000Z'); + + // 旧单键快照在完整迁移提交后退役 + expect(await readRaw(KEY)).toBeNull(); + + // 重新水合得到完整状态(无感升级) + const rehydrated = await debouncedPersistStorage.getItem(KEY); + expect(rehydrated?.version).toBe(16); + expect(rehydrated?.state).toEqual(JSON.parse(JSON.stringify(nextValue.state))); + }); + + it('skips the write entirely when two real partialize calls produce equal content', async () => { + // 生产路径中 partialize 每次都重建小对象,只有 core 按内容判脏才能跳过。 + const state = buildState(); + debouncedPersistStorage.setItem(KEY, { state: appPersistenceOptions.partialize!(state as never) as Record, version: 16 }); + await flushPendingPersistSnapshot(); + const savedAtAfterFirstWrite = (await readMeta())?.savedAt; + + debouncedPersistStorage.setItem(KEY, { state: appPersistenceOptions.partialize!(state as never) as Record, version: 16 }); + await flushPendingPersistSnapshot(); + expect((await readMeta())?.savedAt).toBe(savedAtAfterFirstWrite); + }); + + it('rewrites only the core shard when a small preference changes', async () => { + const state = buildState(); + debouncedPersistStorage.setItem(KEY, { state, version: 16 }); + await flushPendingPersistSnapshot(); + const repositoriesShardBefore = await readRaw(shardKeyFor(KEY, 'repositories')); + const gistsShardBefore = await readRaw(shardKeyFor(KEY, 'gists')); + + // 展开保留重数据字段引用,模拟真实 partialize 行为 + const nextState = { ...state, language: 'en' }; + debouncedPersistStorage.setItem(KEY, { state: nextState, version: 16 }); + await flushPendingPersistSnapshot(); + + const coreShard = await readShardState('core'); + expect(coreShard.language).toBe('en'); + // 重数据字段引用未变:分片内容保持原样 + expect(await readRaw(shardKeyFor(KEY, 'repositories'))).toBe(repositoriesShardBefore); + expect(await readRaw(shardKeyFor(KEY, 'gists'))).toBe(gistsShardBefore); + }); + + it('rewrites the repositories shard when its array reference changes but leaves other heavy shards', async () => { + const state = buildState(); + debouncedPersistStorage.setItem(KEY, { state, version: 16 }); + await flushPendingPersistSnapshot(); + const gistsShardBefore = await readRaw(shardKeyFor(KEY, 'gists')); + + const nextState = { ...state, repositories: [{ id: 1, name: 'repo-one', ai_summary: 'new' }] }; + debouncedPersistStorage.setItem(KEY, { state: nextState, version: 16 }); + await flushPendingPersistSnapshot(); + + expect((await readShardState('repositories')).repositories).toEqual([ + { id: 1, name: 'repo-one', ai_summary: 'new' }, + ]); + expect(await readRaw(shardKeyFor(KEY, 'gists'))).toBe(gistsShardBefore); + }); + + it('does not rewrite heavy shards on a plain boot after data has converged', async () => { + // 契约:水合 → merge → partialize 的启动序列不得重写任何重数据分片 + //(accountWorkspaces/trendingSnapshots 的规整化引用保留 + lastSync 归 core)。 + const seedState = appPersistenceOptions.partialize!(buildState() as never); + debouncedPersistStorage.setItem(KEY, { state: seedState as Record, version: 16 }); + await flushPendingPersistSnapshot(); + await markShardsOnDisk(); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + const merged = appPersistenceOptions.merge!(hydrated!.state as never, createInitialState() as never); + debouncedPersistStorage.setItem(KEY, { state: appPersistenceOptions.partialize!(merged as never) as Record, version: 16 }); + await flushPendingPersistSnapshot(); + + expect(await rewrittenShards()).toEqual([]); + }); + + it('serializes overlapping idle/flush writes so the dirty baseline never trails the disk', async () => { + // 回归:写入 A 提交期间(版本已过期),写入 B 若以过期基准判脏会跳过写盘, + // 把 A 的旧值留在磁盘上且缓存不自知。 + const state = buildState(); + debouncedPersistStorage.setItem(KEY, { state, version: 16 }); + await flushPendingPersistSnapshot(); + + const stateLight = { ...state, theme: 'light' }; + const stateDark = { ...state, theme: 'dark' }; + commitGate.arm(); + debouncedPersistStorage.setItem(KEY, { state: stateLight, version: 16 }); + const flushInFlight = flushPendingPersistSnapshot(); + await commitGate.waitReached(); // 写入 A 已完成判脏,在真实提交点被确定性拦停 + debouncedPersistStorage.setItem(KEY, { state: stateDark, version: 16 }); + const flushAfterDark = flushPendingPersistSnapshot(); // B 排队在 A 之后 + commitGate.release(); + await flushInFlight; + await flushAfterDark; + + expect((await readShardState('core')).theme).toBe('dark'); + const rehydrated = await debouncedPersistStorage.getItem(KEY); + expect(rehydrated?.state).toMatchObject({ theme: 'dark', language: 'zh' }); + }); + + it('rebuilds the full shard set when removeItem follows a write still in flight', async () => { + const state = buildState(); + commitGate.arm(); + debouncedPersistStorage.setItem(KEY, { state, version: 16 }); + const inFlightWrite = flushPendingPersistSnapshot(); + await commitGate.waitReached(); // 写入 A 已完成判脏,在真实提交点被确定性拦停 + debouncedPersistStorage.removeItem(KEY); + const nextState = { ...state, language: 'en' }; + debouncedPersistStorage.setItem(KEY, { state: nextState, version: 16 }); + const flushAfterClear = flushPendingPersistSnapshot(); // 排队在删除之后 + commitGate.release(); + await inFlightWrite; + await flushAfterClear; + + // 删除后的首个写入必须全量重建:meta 与全部分片一致,不存在缺失分片 + const rehydrated = await debouncedPersistStorage.getItem(KEY); + expect(rehydrated?.state).toMatchObject({ theme: 'dark', language: 'en' }); + expect(await readMeta()).not.toBeNull(); + expect(await readShardState('gists')).toMatchObject({ gists: [{ id: 'gist-1' }] }); + expect(await readShardState('repositories')).toMatchObject({ + repositories: [{ id: 1, name: 'repo-one' }], + }); + }); + + it('merges persisted shards with the legacy snapshot when some shards are missing, then converges', async () => { + // 模拟迁移中途崩溃:meta + 部分分片已提交,旧单键快照仍在 + const legacyState = { ...buildState(), gists: [{ id: 'legacy-gist' }], theme: 'light' }; + await indexedDBStorage.setItem(KEY, JSON.stringify({ state: legacyState, version: 16 })); + await setStorageEntries(KEY, [ + [metaKeyFor(KEY), JSON.stringify({ format: 1, version: 16, shards: [...PERSISTENCE_SHARD_NAMES], savedAt: 't0' })], + [shardKeyFor(KEY, 'core'), JSON.stringify({ format: 1, version: 16, state: { theme: 'dark', language: 'zh' } })], + ]); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + // 存在的分片内容(更新)覆盖旧快照,缺失分片回退旧快照 + expect(hydrated?.state).toMatchObject({ theme: 'dark', language: 'zh', gists: [{ id: 'legacy-gist' }] }); + + // 下一次写入强制提交全部分片并退役旧键(收敛迁移) + debouncedPersistStorage.setItem(KEY, { state: buildState(), version: 16 }); + await flushPendingPersistSnapshot(); + + expect(await readRaw(KEY)).toBeNull(); + expect(await readShardState('gists')).toMatchObject({ gists: [{ id: 'gist-1' }] }); + const rehydrated = await debouncedPersistStorage.getItem(KEY); + expect(rehydrated?.state).toMatchObject({ theme: 'dark', gists: [{ id: 'gist-1' }] }); + }); + + it('assembles a full shard set and retires a leftover legacy key on read', async () => { + // 模拟提交成功但清理前崩溃:分片齐全 + 旧键残留 + const value: StorageValue = { state: buildState(), version: 16 }; + debouncedPersistStorage.setItem(KEY, value); + await flushPendingPersistSnapshot(); + await indexedDBStorage.setItem(KEY, JSON.stringify({ state: { stale: true }, version: 16 })); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + expect(hydrated?.state).toEqual(JSON.parse(JSON.stringify(value.state))); + await tick(); + expect(await readRaw(KEY)).toBeNull(); + }); + + it('falls back to the legacy snapshot when the meta format is unknown', async () => { + const legacyValue: StorageValue = { state: buildState(), version: 16 }; + await indexedDBStorage.setItem(KEY, JSON.stringify(legacyValue)); + await setStorageEntries(KEY, [ + [metaKeyFor(KEY), JSON.stringify({ format: 999, version: 99, shards: ['core'], savedAt: 't0' })], + ]); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + expect(hydrated).toEqual(JSON.parse(JSON.stringify(legacyValue))); + }); + + it('treats an incomplete or duplicated shard list as an invalid meta and keeps the legacy snapshot', async () => { + // H2 回归:格式合法但只列出部分分片的 meta 不得绕过恢复路径, + // 也不得退役唯一可兜底的 legacy 单键快照。 + const legacyValue: StorageValue = { + state: { theme: 'light', repositories: [{ id: 1 }], gists: [{ id: 'keep' }] }, + version: 16, + }; + await indexedDBStorage.setItem(KEY, JSON.stringify(legacyValue)); + for (const shards of [['core'], ['core', 'core'], [...PERSISTENCE_SHARD_NAMES.slice(0, -1)], ['core', 'unknown-shard']]) { + await setStorageEntries(KEY, [ + [metaKeyFor(KEY), JSON.stringify({ format: 1, version: 16, shards, savedAt: 't0' })], + [shardKeyFor(KEY, 'core'), JSON.stringify({ format: 1, version: 16, state: { theme: 'dark' } })], + ]); + await indexedDBStorage.setItem(KEY, JSON.stringify(legacyValue)); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + expect(hydrated?.state).toMatchObject({ theme: 'light', gists: [{ id: 'keep' }] }); + await tick(); + expect(await readRaw(KEY), `legacy must survive invalid meta ${JSON.stringify(shards)}`).not.toBeNull(); + } + }); + + it('salvages readable orphaned shards when meta and legacy are both gone', async () => { + await setStorageEntries(KEY, [ + [shardKeyFor(KEY, 'core'), JSON.stringify({ format: 1, version: 16, state: { theme: 'dark', language: 'zh' } })], + [shardKeyFor(KEY, 'repositories'), JSON.stringify({ format: 1, version: 16, state: { repositories: [{ id: 7 }] } })], + ]); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + expect(hydrated?.state).toMatchObject({ theme: 'dark', repositories: [{ id: 7 }] }); + + // 打捞后的首次写入以空基准全量重建(含 meta 自愈) + debouncedPersistStorage.setItem(KEY, { state: buildState(), version: 16 }); + await flushPendingPersistSnapshot(); + expect(await readMeta()).not.toBeNull(); + expect(await readShardState('gists')).toMatchObject({ gists: [{ id: 'gist-1' }] }); + }); + + it('treats corrupt shard payloads as missing and recovers from the legacy snapshot', async () => { + const legacyState = buildState(); + await indexedDBStorage.setItem(KEY, JSON.stringify({ state: legacyState, version: 16 })); + await setStorageEntries(KEY, [ + [metaKeyFor(KEY), JSON.stringify({ format: 1, version: 16, shards: [...PERSISTENCE_SHARD_NAMES], savedAt: 't0' })], + [shardKeyFor(KEY, 'core'), 'not-json'], + ]); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + expect(hydrated?.state).toEqual(JSON.parse(JSON.stringify(legacyState))); + }); + + it('hydrates best-effort from readable shards when nothing else is available', async () => { + await setStorageEntries(KEY, [ + [metaKeyFor(KEY), JSON.stringify({ format: 1, version: 16, shards: [...PERSISTENCE_SHARD_NAMES], savedAt: 't0' })], + [shardKeyFor(KEY, 'core'), JSON.stringify({ format: 1, version: 16, state: { theme: 'dark' } })], + ]); + + const hydrated = await debouncedPersistStorage.getItem(KEY); + expect(hydrated?.version).toBe(16); + expect(hydrated?.state).toEqual({ theme: 'dark' }); + }); + + it('keeps the fallback authority marker while localStorage still holds shard keys', async () => { + // 回归(CodeRabbit r4165367360):IDB 批量写成功后无条件清除权威标记的做法, + // 在「兜底写入 + 兜底期 IDB 旧键删除失败」组合下,会让旧 IDB 分片在下次水合 + // 遮蔽 localStorage 里的新值——用户偏好静默回退,且判脏基准已按新值更新、 + // 永不自愈。 + const state = buildState(); + debouncedPersistStorage.setItem(KEY, { state, version: 16 }); + await flushPendingPersistSnapshot(); + + // 兜底插曲:仅 core 经兜底路径写入 localStorage;模拟兜底期间的 IDB 删除 + // 失败(IDB 刚出过故障,这并不罕见),旧 core 留在 IndexedDB + const fallbackCore = JSON.stringify({ format: 1, version: 16, state: { theme: 'light', language: 'en' } }); + const originalDelete = IDBObjectStore.prototype.delete; + IDBObjectStore.prototype.delete = function () { throw new Error('simulated delete failure'); }; + try { + await writeEntriesToFallbackStorage(KEY, [[shardKeyFor(KEY, 'core'), fallbackCore]]); + } finally { + IDBObjectStore.prototype.delete = originalDelete; + } + expect(window.localStorage.getItem(`${KEY}#fallback`)).not.toBeNull(); + expect(await readRaw(shardKeyFor(KEY, 'core'))).not.toBeNull(); // 旧 IDB core 未被删掉 + + // 之后一批不含 core 的写入经 IndexedDB 成功:LS 仍持有较新的 core → 标记必须保留 + await setStorageEntries(KEY, [ + [shardKeyFor(KEY, 'releases'), JSON.stringify({ format: 1, version: 16, state: { releases: [{ id: 202 }] } })], + [metaKeyFor(KEY), JSON.stringify({ format: 1, version: 16, shards: [...PERSISTENCE_SHARD_NAMES], savedAt: 't2' })], + ]); + expect(window.localStorage.getItem(`${KEY}#fallback`)).not.toBeNull(); + + // 水合读到 localStorage 里的新 core,而不是 IndexedDB 里的旧 core + const hydrated = await debouncedPersistStorage.getItem(KEY); + expect(hydrated?.state).toMatchObject({ + theme: 'light', + language: 'en', + releases: [{ id: 202 }], + gists: [{ id: 'gist-1' }], + }); + + // 自愈:当批写入覆盖 core 且经 IDB 成功后,LS 镜像被清、不再有派生键残留, + // 标记这才清除;之后的读取回到 IDB 优先 + const healedCore = JSON.stringify({ format: 1, version: 16, state: { theme: 'dark', language: 'zh' } }); + await setStorageEntries(KEY, [[shardKeyFor(KEY, 'core'), healedCore]]); + expect(window.localStorage.getItem(`${KEY}#fallback`)).toBeNull(); + expect(await readStrict(shardKeyFor(KEY, 'core'))).toBe(healedCore); + }); + + it('refuses to write after a shard read failure so real data cannot be overwritten', async () => { + // F2r2 回归:读取失败(区别于「键不存在」)后的水合结果不完整, + // 后续写入必须被拒绝,否则会用默认值覆盖磁盘上的真实分片。 + const state = buildState(); + debouncedPersistStorage.setItem(KEY, { state, version: 16 }); + await flushPendingPersistSnapshot(); + const savedAtBefore = (await readMeta())?.savedAt; + const gistsBefore = await readRaw(shardKeyFor(KEY, 'gists')); + + const originalGet = IDBObjectStore.prototype.get; + IDBObjectStore.prototype.get = function () { throw new Error('simulated transient IDB failure'); }; + try { + const hydrated = await debouncedPersistStorage.getItem(KEY); + expect(hydrated).toBeNull(); + } finally { + IDBObjectStore.prototype.get = originalGet; + } + + debouncedPersistStorage.setItem(KEY, { state: { ...state, language: 'en' }, version: 16 }); + await flushPendingPersistSnapshot(); + + // 写入被拒绝:磁盘保持原样 + expect((await readMeta())?.savedAt).toBe(savedAtBefore); + expect(await readRaw(shardKeyFor(KEY, 'gists'))).toBe(gistsBefore); + + // 读取恢复后写入重新可用 + const recovered = await debouncedPersistStorage.getItem(KEY); + expect(recovered?.state).toMatchObject({ theme: 'dark' }); + debouncedPersistStorage.setItem(KEY, { state: { ...state, language: 'en' }, version: 16 }); + await flushPendingPersistSnapshot(); + expect((await readShardState('core')).language).toBe('en'); + }); + + it('removes every derived key on removeItem', async () => { + const value: StorageValue = { state: buildState(), version: 16 }; + debouncedPersistStorage.setItem(KEY, value); + await flushPendingPersistSnapshot(); + expect(await readMeta()).not.toBeNull(); + + debouncedPersistStorage.removeItem(KEY); + await tick(); + + expect(await readRaw(KEY)).toBeNull(); + expect(await readMeta()).toBeNull(); + for (const shard of PERSISTENCE_SHARD_NAMES) { + expect(await readRaw(shardKeyFor(KEY, shard))).toBeNull(); + } + }); + + it('removeItem resolves only after the on-disk removal has completed', async () => { + // 「清空所有数据」后 2 秒 reload:removeItem 必须可 await(zustand v4 的 + // clearStorage 不回传 Promise),否则 reload 可能早于删除提交,数据复活。 + debouncedPersistStorage.setItem(KEY, { state: buildState(), version: 16 }); + await flushPendingPersistSnapshot(); + expect(await readMeta()).not.toBeNull(); + + // 不 sleep:返回的 Promise 本身应覆盖整条串行链(排队写入 → 清盘 → 簿记重置) + await expect(debouncedPersistStorage.removeItem(KEY)).resolves.toBeUndefined(); + + expect(await readRaw(KEY)).toBeNull(); + expect(await readMeta()).toBeNull(); + expect(await readRaw(shardKeyFor(KEY, 'core'))).toBeNull(); + }); + + it('removePersistedSnapshot rejects when the on-disk removal fails, without poisoning the write chain', async () => { + // 回归(CodeRabbit r4165367349):删除失败必须如实传给调用方(清空数据流 + // 据此跳过状态重置与 reload,而不是把残留快照当作已删除);同时共享写链 + // 不 reject、簿记仍重置,后续写入全量重建、状态恢复一致。 + debouncedPersistStorage.setItem(KEY, { state: buildState(), version: 16 }); + await flushPendingPersistSnapshot(); + expect(await readMeta()).not.toBeNull(); + + const originalDelete = IDBObjectStore.prototype.delete; + IDBObjectStore.prototype.delete = function () { throw new Error('simulated delete failure'); }; + try { + await expect(removePersistedSnapshot(KEY)).rejects.toThrow('simulated delete failure'); + } finally { + IDBObjectStore.prototype.delete = originalDelete; + } + + // 磁盘上的旧数据未被删除(失败如实暴露,而不是被当作成功) + expect(await readMeta()).not.toBeNull(); + + // 链未被毒化、簿记已重置:后续写入全量重建,重新水合得到一致状态 + const nextState = { ...buildState(), language: 'en' }; + debouncedPersistStorage.setItem(KEY, { state: nextState, version: 16 }); + await flushPendingPersistSnapshot(); + expect((await readShardState('core')).language).toBe('en'); + const rehydrated = await debouncedPersistStorage.getItem(KEY); + expect(rehydrated?.state).toMatchObject({ theme: 'dark', language: 'en' }); + }); +}); + +describe('indexedDbStorage batch helpers', () => { + it('writes every entry to localStorage on the fallback path', async () => { + await writeEntriesToFallbackStorage(KEY, [['fallback-a', '1'], ['fallback-b', '2']]); + expect(window.localStorage.getItem('fallback-a')).toBe('1'); + expect(window.localStorage.getItem('fallback-b')).toBe('2'); + window.localStorage.removeItem('fallback-a'); + window.localStorage.removeItem('fallback-b'); + }); + + it('rolls back the whole fallback batch when any key write fails', async () => { + // Node ≥24 的原生 localStorage 经 vitest 拷入后每次访问可能返回新包装对象, + // vi.spyOn 拦截不可靠;整体替换为可控 fake 保证模块与测试看到同一实例。 + // 批次内的 fallback-a 预置旧值:回滚必须恢复旧值而不是删除 + //(meta 仍引用该分片键,删除会被水合误判为分片缺失)。 + const backing = new Map([ + ['fallback-a', 'old-committed-core'], + ['fallback-c', 'previous'], + ]); + const failingStorage: Storage = { + clear: () => backing.clear(), + getItem: (key: string) => (backing.has(key) ? (backing.get(key) as string) : null), + key: (index: number) => Array.from(backing.keys())[index] ?? null, + removeItem: (key: string) => { + backing.delete(key); + }, + setItem: (key: string, value: string) => { + if (key === 'fallback-b') throw new Error('quota exceeded'); + backing.set(key, String(value)); + }, + get length() { + return backing.size; + }, + }; + const savedDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage'); + Object.defineProperty(window, 'localStorage', { configurable: true, value: failingStorage }); + try { + await expect(writeEntriesToFallbackStorage(KEY, [['fallback-a', '1'], ['fallback-b', '2']])).rejects.toThrow(); + // 已覆盖的键恢复旧值;本批新增的键回滚删除;批次外的键不受影响 + expect(backing.get('fallback-a')).toBe('old-committed-core'); + expect(backing.has('fallback-b')).toBe(false); + expect(backing.get('fallback-c')).toBe('previous'); + } finally { + if (savedDescriptor) { + Object.defineProperty(window, 'localStorage', savedDescriptor); + } else { + delete (window as unknown as Record).localStorage; + } + } + }); + + it('marks localStorage as the authority after a fallback write so stale IDB values cannot shadow it', async () => { + // F1 回归(CodeRabbit 线程 r4164377998):IDB 批量写失败而回退写入成功后, + // 旧的 IndexedDB 分片/meta 必须失效,否则重启水合会静默回滚到旧状态。 + await setStorageEntries(KEY, [[`${KEY}#shard:core`, 'OLD-FROM-IDB'], [metaKeyFor(KEY), '{"format":1}']]); + + const originalPut = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function () { throw new Error('simulated quota exceeded'); }; + try { + await setStorageEntries(KEY, [[`${KEY}#shard:core`, 'NEW-FROM-FALLBACK']]); + } finally { + IDBObjectStore.prototype.put = originalPut; + } + + // 严格读必须拿到回退写入的新值(而非旧 IDB 值) + expect(await readStrict(`${KEY}#shard:core`)).toBe('NEW-FROM-FALLBACK'); + // localStorage 权威标记已置位 + expect(window.localStorage.getItem(`${KEY}#fallback`)).not.toBeNull(); + + // IDB 恢复后的下一次成功写入清除权威标记并回写 IDB + await setStorageEntries(KEY, [[`${KEY}#shard:core`, 'NEWER-FROM-IDB']]); + expect(await readStrict(`${KEY}#shard:core`)).toBe('NEWER-FROM-IDB'); + }); + + it('falls back to localStorage for batch-read keys missing from IndexedDB even without the authority marker', async () => { + // 极端角落(如兜底标记自身写入失败):IDB 无此键但 localStorage 有值且无标记, + // 批量严格读必须与单键严格读一致地回退 localStorage,而不是判成分片缺失。 + window.localStorage.setItem(`${KEY}#shard:core`, 'LS-ONLY'); + try { + expect(await getStorageEntriesStrict(KEY, [`${KEY}#shard:core`])).toEqual(['LS-ONLY']); + // 对照:IDB 有该键时值以 IDB 为准(LS 镜像不会被误用) + await setStorageEntries(KEY, [[`${KEY}#shard:core`, 'IDB-VALUE']]); + expect(await getStorageEntriesStrict(KEY, [`${KEY}#shard:core`])).toEqual(['IDB-VALUE']); + } finally { + window.localStorage.removeItem(`${KEY}#shard:core`); + await indexedDBStorage.removeItem(`${KEY}#shard:core`); + } + }); + + it('writes batch entries through the IndexedDB path and reads them back', async () => { + await setStorageEntries(KEY, [['idb-a', '1'], ['idb-b', '2']]); + expect(await indexedDBStorage.getItem('idb-a')).toBe('1'); + expect(await indexedDBStorage.getItem('idb-b')).toBe('2'); + await indexedDBStorage.removeItem('idb-a'); + await indexedDBStorage.removeItem('idb-b'); + }); + + it('migrates an existing localStorage snapshot when reading a key missing from IndexedDB', async () => { + window.localStorage.setItem('migrate-me', '{"legacy":true}'); + try { + expect(await indexedDBStorage.getItem('migrate-me')).toBe('{"legacy":true}'); + // 读取即迁移:快照进入 IndexedDB,localStorage 镜像被清除 + expect(window.localStorage.getItem('migrate-me')).toBeNull(); + expect(await readRaw('migrate-me')).toBe('{"legacy":true}'); + } finally { + await indexedDBStorage.removeItem('migrate-me'); + } + }); + + it('sweeps derived `${name}#` keys from localStorage on removeItem', async () => { + window.localStorage.setItem(KEY, 'legacy'); + window.localStorage.setItem(`${KEY}#shard:core`, '{}'); + window.localStorage.setItem(`${KEY}#meta`, '{}'); + window.localStorage.setItem('unrelated-key', 'keep'); + + await indexedDBStorage.removeItem(KEY); + + expect(window.localStorage.getItem(KEY)).toBeNull(); + expect(window.localStorage.getItem(`${KEY}#shard:core`)).toBeNull(); + expect(window.localStorage.getItem(`${KEY}#meta`)).toBeNull(); + expect(window.localStorage.getItem('unrelated-key')).toBe('keep'); + window.localStorage.removeItem('unrelated-key'); + }); +}); diff --git a/src/store/persistence/storage.ts b/src/store/persistence/storage.ts index 9ccf843b3..d66d49e38 100644 --- a/src/store/persistence/storage.ts +++ b/src/store/persistence/storage.ts @@ -1,7 +1,25 @@ import type { PersistStorage, StorageValue } from 'zustand/middleware'; -import { indexedDBStorage } from '../../services/indexedDbStorage'; +import { + getStorageEntriesStrict, + getStorageItemStrict, + hasLegacySnapshot, + indexedDBStorage, + removeLegacySnapshot, + setStorageEntries, +} from '../../services/indexedDbStorage'; import { logger } from '../../services/logger'; +import { + PERSISTENCE_SHARD_FORMAT, + PERSISTENCE_SHARD_NAMES, + buildShardViews, + findDirtyShards, + metaKeyFor, + shardKeyFor, + type PersistenceShardName, + type ShardMeta, + type ShardPayload, +} from './shards'; const scheduleIdleTask = (callback: () => void): number => { if (typeof window === 'undefined') { @@ -31,6 +49,31 @@ let latestPersistValue: StorageValue | null = null; let persistWriteVersion = 0; let persistFlushListenersRegistered = false; +// 分片持久化的会话内簿记: +// - lastWrittenShards 记录每个分片上次成功写入的字段引用,作为判脏基准; +// - committedShards 记录本会话已成功提交过的分片,用于判定 legacy 旧快照可以退役; +// - legacySnapshotHydrated 表示本次会话从旧版单键快照(或部分分片+旧快照兜底)水合, +// 下一次写入将强制提交全部分片,以便一次性完成迁移并退役旧键; +// - hydratedIncomplete 表示最近一次水合存在读取失败(IDB 故障/超时)。此时水合 +// 结果不完整,绝不能写盘——否则会把磁盘上仍然完好的真实分片用默认值覆盖。 +// 已知权衡(多标签页/多窗口):判脏基准是各标签页自己水合时的快照,互相不感知; +// 一个标签页清盘后另一标签页的部分写入可能产生「meta 全量声明 + 部分分片缺失」, +// 读取端会走合并/best-effort 降级路径。与旧单键方案的整快照 LWW 相比,撕裂窗口 +// 更小但存在;跨标签页仲裁(BroadcastChannel/版本号)留作后续跟进。 +type ShardViews = Record>; +let lastWrittenShards: ShardViews | null = null; +const committedShards = new Set(); +let legacySnapshotHydrated = false; +let hydratedIncomplete = false; + +const emptyShardViews = (): ShardViews => { + const views = {} as ShardViews; + for (const shard of PERSISTENCE_SHARD_NAMES) { + views[shard] = {}; + } + return views; +}; + const cancelPendingPersistTasks = (): void => { if (persistTimeoutId) { clearTimeout(persistTimeoutId); @@ -43,57 +86,374 @@ const cancelPendingPersistTasks = (): void => { } }; -const writePersistSnapshot = ( +const parseShardPayload = (raw: string | null): { state: Record; version: number } | null => { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as ShardPayload | null; + if (!parsed || parsed.format !== PERSISTENCE_SHARD_FORMAT || !parsed.state + || typeof parsed.state !== 'object') { + return null; + } + return { state: parsed.state, version: typeof parsed.version === 'number' ? parsed.version : 0 }; + } catch { + return null; + } +}; + +/** + * meta 是分片布局的目录,必须视为不可信输入: + * - 分片名必须是已知分片,且集合与 PERSISTENCE_SHARD_NAMES 完全一致(无缺失、 + * 无重复、无未知项)。不完整的 meta 会让字段静默消失并被当作「分片齐全」, + * 进而过早退役唯一的兜底(legacy 单键快照)。 + * - meta 本身读取失败(IDB 故障/超时)由调用方区分处理。 + */ +const parseMeta = (raw: string | null): ShardMeta | null => { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as ShardMeta | null; + if (!parsed || parsed.format !== PERSISTENCE_SHARD_FORMAT + || typeof parsed.version !== 'number' + || !Array.isArray(parsed.shards) + || parsed.shards.length !== PERSISTENCE_SHARD_NAMES.length + || !PERSISTENCE_SHARD_NAMES.every((shard) => parsed.shards.includes(shard))) { + return null; + } + return parsed; + } catch { + return null; + } +}; + +const readLegacySnapshot = async ( + name: string, +): Promise | null> => { + const raw = await getStorageItemStrict(name, name); + if (!raw) return null; + try { + return JSON.parse(raw) as StorageValue; + } catch { + return null; + } +}; + +/** meta 缺失/损坏时的打捞:按已知分片名直接读取仍可解析的分片载荷。 */ +const salvageOrphanedShards = async (name: string): Promise<{ + parts: Record | null; + version: number | null; + readFailed: boolean; + foundButUnreadable: boolean; +}> => { + const keys = PERSISTENCE_SHARD_NAMES.map((shard) => shardKeyFor(name, shard)); + let results: Array; + try { + results = await getStorageEntriesStrict(name, keys); + } catch (error) { + logger.errorFromError('store.persist', 'Failed to salvage orphaned persistence shards', error); + return { parts: null, version: null, readFailed: true, foundButUnreadable: false }; + } + + const parts: Record = {}; + let version: number | null = null; + let foundButUnreadable = false; + PERSISTENCE_SHARD_NAMES.forEach((_shard, index) => { + const parsed = parseShardPayload(results[index]); + if (results[index] !== null && !parsed) { + foundButUnreadable = true; + } + if (!parsed) return; + Object.assign(parts, parsed.state); + version = version ?? parsed.version; + }); + return { + parts: Object.keys(parts).length > 0 ? parts : null, + version, + readFailed: false, + foundButUnreadable, + }; +}; + +/** + * 读取持久化快照。 + * + * 读取顺序(保证升级无感、历史数据零丢失): + * 1. meta(精确分片集)+ 全部分片齐全 → 拼装新格式快照; + * 2. meta 存在但部分分片缺失/损坏 → 存在的分片内容与旧版单键快照合并 + * (分片提交时间晚于旧快照,是各字段的最新已知状态); + * 3. meta 缺失/损坏/不完整 → 回退旧版单键快照(v16 行为,升级首启即此路径); + * 4. legacy 也不存在 → 按已知分片名打捞孤儿分片(meta 被清/损坏但分片健在); + * 5. 任何「读取失败」(区别于「确定不存在」)都会置 hydratedIncomplete, + * 之后拒绝写盘,防止用不完整的水合结果覆盖磁盘上的真实数据。 + */ +const readPersistedSnapshot = async ( + name: string, +): Promise | null> => { + let readFailed = false; + + let meta: ShardMeta | null = null; + try { + meta = parseMeta(await getStorageItemStrict(name, metaKeyFor(name))); + } catch (error) { + readFailed = true; + logger.errorFromError('store.persist', 'Failed to read persistence meta', error); + } + + if (!meta) { + const legacy = await readLegacySnapshot(name).catch(() => { + readFailed = true; + return null; + }); + if (legacy && legacy.state && typeof legacy.state === 'object') { + // meta 不可读但 legacy 单键快照可读:以 legacy 为准(完整快照,写全量安全)。 + legacySnapshotHydrated = true; + lastWrittenShards = buildShardViews(legacy.state as Record); + hydratedIncomplete = false; + return legacy; + } + + // legacy 不存在:打捞孤儿分片(meta 键丢失/损坏但分片仍可读)。 + const salvage = await salvageOrphanedShards(name); + if (salvage.readFailed) readFailed = true; + if (salvage.parts) { + logger.errorFromError( + 'store.persist', + 'Persistence meta missing but shards are readable; recovering from orphaned shards', + new Error('meta missing'), + ); + // 基准留空:下次写入全量重建(含 meta 自愈);打捞内容会随全量写入写回。 + lastWrittenShards = null; + hydratedIncomplete = false; + return { state: salvage.parts, version: salvage.version ?? 0 }; + } + if (readFailed || salvage.foundButUnreadable) { + // 磁盘上存在持久化数据但当前不可读:拒绝以空状态水合(随后的写入会覆盖真实数据)。 + hydratedIncomplete = true; + logger.errorFromError( + 'store.persist', + 'Persisted data exists but cannot be read; refusing to hydrate and overwrite', + new Error('meta and shards unreadable'), + ); + return null; + } + return null; + } + + let shardResults: Array; + try { + shardResults = await getStorageEntriesStrict( + name, + PERSISTENCE_SHARD_NAMES.map((shard) => shardKeyFor(name, shard)), + ); + } catch (error) { + readFailed = true; + logger.errorFromError('store.persist', 'Failed to read persistence shards', error); + shardResults = PERSISTENCE_SHARD_NAMES.map(() => null); + } + + const parts: Record = {}; + const views = emptyShardViews(); + const missing: PersistenceShardName[] = []; + PERSISTENCE_SHARD_NAMES.forEach((shard, index) => { + const parsed = parseShardPayload(shardResults[index]); + if (!parsed) { + missing.push(shard); + return; + } + Object.assign(parts, parsed.state); + views[shard] = parsed.state; + }); + + if (missing.length === 0 && !readFailed) { + lastWrittenShards = views; + // 分片齐全且读取成功:旧版单键快照已完成使命,确认仍存在时才退役(幂等、廉价)。 + if (await hasLegacySnapshot(name)) { + void Promise.resolve(removeLegacySnapshot(name)).catch(() => undefined); + } + return { state: parts, version: meta.version }; + } + + if (readFailed) { + logger.warn('store.persist', 'Some persistence shards failed to read; merge degraded', { + missing, + }); + } else { + logger.warn('store.persist', 'Some persistence shards are missing; merging with legacy snapshot', { + missing, + shards: meta.shards, + }); + } + + const legacy = await readLegacySnapshot(name).catch(() => { + readFailed = true; + return null; + }); + if (!legacy || !legacy.state || typeof legacy.state !== 'object') { + // 无旧快照可兜底(极端:分片被外部清除),尽力返回可读分片,缺失字段按空处理。 + logger.errorFromError( + 'store.persist', + 'Persistence shards missing and no legacy snapshot available; hydrating best-effort', + new Error(`missing shards: ${missing.join(', ')}`), + ); + lastWrittenShards = views; + hydratedIncomplete = readFailed; + return { state: parts, version: meta.version }; + } + + // 以旧快照为底、存在的分片覆盖:每个字段都取最新已知状态,避免回退到整体旧数据。 + const legacyState = legacy.state as Record; + const legacyViews = buildShardViews(legacyState); + const mergedState: Record = { ...legacyState, ...parts }; + PERSISTENCE_SHARD_NAMES.forEach((shard, index) => { + const parsed = parseShardPayload(shardResults[index]); + views[shard] = parsed?.state ?? legacyViews[shard]; + }); + lastWrittenShards = views; + legacySnapshotHydrated = true; + hydratedIncomplete = readFailed; + return { state: mergedState, version: meta.version }; +}; + +const writeShardedSnapshot = async ( name: string, value: StorageValue, - version: number, - source: 'idle' | 'flush' -): void => { - if (latestPersistValue === null || latestPersistName !== name || persistWriteVersion !== version) { + writeVersion: number, + source: 'idle' | 'flush', +): Promise => { + if (latestPersistValue === null || latestPersistName !== name || persistWriteVersion !== writeVersion) { + return; + } + + if (hydratedIncomplete) { + // 最近一次水合存在读取失败:水合结果不完整,写盘会用默认值覆盖磁盘上 + // 仍然完好的真实分片。拒绝写入直到下一次完整读取成功。 + logger.errorFromError( + 'store.persist', + 'Skipping persist write: last hydration was incomplete; refusing to overwrite', + new Error('hydrated incomplete'), + { source }, + ); return; } - const startedAt = performance.now(); + // 载荷中的 version 必须是 persist 版本号(水合时驱动 migrate), + // writeVersion 仅用于丢弃过期的写入调度。 + const persistVersion = typeof value.version === 'number' ? value.version : 0; + const state = (value.state ?? {}) as Record; + const views = buildShardViews(state); + // 从旧版快照水合的会话:首次写入强制提交全部分片,完成迁移后即可退役旧键。 + const dirty = legacySnapshotHydrated + ? [...PERSISTENCE_SHARD_NAMES] + : findDirtyShards(views, lastWrittenShards); + if (dirty.length === 0) return; + + const SHARD_SIZE_WARN_BYTES = 5 * 1024 * 1024; + const entries: Array = []; try { - const str = JSON.stringify(value); - const stringifyMs = Math.round(performance.now() - startedAt); - const writeStartedAt = performance.now(); - void Promise.resolve(indexedDBStorage.setItem(name, str)) - .then(() => { - const writeMs = Math.round(performance.now() - writeStartedAt); - if (writeMs > 50) { - logger.warn('store.persist', 'Large state IndexedDB write completed', { - source, - writeMs, - bytes: str.length, - }); - } - }) - .catch((error: unknown) => { - const writeMs = Math.round(performance.now() - writeStartedAt); - logger.errorFromError('store.persist', 'IndexedDB write failed', error, { + for (const shard of dirty) { + const stringifyStartedAt = performance.now(); + const payload: ShardPayload = { + format: PERSISTENCE_SHARD_FORMAT, + version: persistVersion, + state: views[shard], + }; + const serialized = JSON.stringify(payload); + const stringifyMs = Math.round(performance.now() - stringifyStartedAt); + if (stringifyMs > 50) { + logger.warn('store.persist', 'Large state stringify completed', { source, - writeMs, - bytes: str.length, + shard, + stringifyMs, + bytes: serialized.length, }); - }); - if (stringifyMs > 50) { - logger.warn('store.persist', 'Large state stringify completed', { - source, - stringifyMs, - bytes: str.length, - }); + } else if (serialized.length > SHARD_SIZE_WARN_BYTES) { + logger.warn('store.persist', 'Large state shard serialized', { + source, + shard, + bytes: serialized.length, + }); + } + entries.push([shardKeyFor(name, shard), serialized]); } + const meta: ShardMeta = { + format: PERSISTENCE_SHARD_FORMAT, + version: persistVersion, + shards: [...PERSISTENCE_SHARD_NAMES], + savedAt: new Date().toISOString(), + }; + entries.push([metaKeyFor(name), JSON.stringify(meta)]); } catch (e) { logger.errorFromError('store.persist', 'Failed to stringify state for persistence', e); + return; + } + + const writeStartedAt = performance.now(); + try { + await setStorageEntries(name, entries); + } catch (error) { + // 提交失败(原子回滚):旧快照原样保留,无数据丢失,等待下次变更重试。 + logger.errorFromError('store.persist', 'Sharded persist write failed; previous snapshot kept', error, { + source, + shards: dirty, + }); + return; } + const writeMs = Math.round(performance.now() - writeStartedAt); + if (writeMs > 50) { + logger.warn('store.persist', 'Large state IndexedDB write completed', { + source, + shards: dirty, + writeMs, + }); + } + + // 即使本写入期间已有更新的调度(版本过期),磁盘上落盘的也是本批内容, + // 判脏缓存必须如实反映磁盘状态;更新的写入排在本批之后,会以该基准重新判脏。 + const nextCache: ShardViews = { ...(lastWrittenShards ?? emptyShardViews()) }; + for (const shard of dirty) { + nextCache[shard] = views[shard]; + committedShards.add(shard); + } + lastWrittenShards = nextCache; + + // 全部分片至少成功提交一次后,旧版单键快照才可安全退役(保证可回退读取)。 + if (legacySnapshotHydrated && PERSISTENCE_SHARD_NAMES.every((shard) => committedShards.has(shard))) { + legacySnapshotHydrated = false; + logger.info('store.persist', 'Legacy single-key snapshot retired after sharded migration'); + await removeLegacySnapshot(name); + } +}; + +let writeChain: Promise = Promise.resolve(); + +/** + * 串行化分片写入:判脏必须基于上一次已完成写入的缓存(lastWrittenShards)。 + * idle 写入与 flush 写入可能重叠,若并发执行,后写入者会以过期基准判脏而 + * 跳过写盘,把已撤销的状态留在磁盘上且缓存不自知。 + */ +const enqueueShardedWrite = ( + name: string, + value: StorageValue, + writeVersion: number, + source: 'idle' | 'flush', +): Promise => { + // 链尾兜底 catch:writeChain 永不 reject,调用方(idle 的 void 调度、 + // pagehide 的同步事件处理器)不会产生 unhandled rejection。 + writeChain = writeChain + .catch(() => undefined) + .then(() => writeShardedSnapshot(name, value, writeVersion, source)) + .catch((error: unknown) => { + logger.errorFromError('store.persist', 'Persist write chain failed', error, { source }); + }); + return writeChain; }; -const flushPendingPersistSnapshot = (): void => { - if (latestPersistName === null || latestPersistValue === null) return; +const flushPendingPersistSnapshot = (): Promise => { + if (latestPersistName === null || latestPersistValue === null) return Promise.resolve(); cancelPendingPersistTasks(); - writePersistSnapshot(latestPersistName, latestPersistValue, persistWriteVersion, 'flush'); + const name = latestPersistName; + const value = latestPersistValue; + const scheduledVersion = persistWriteVersion; + return enqueueShardedWrite(name, value, scheduledVersion, 'flush'); }; const registerPersistFlushListeners = (): void => { @@ -112,17 +472,52 @@ const registerPersistFlushListeners = (): void => { } }; +/** + * 清空持久化快照(含 `${name}#` 派生键),返回覆盖整条串行链的 Promise: + * 排队中的写入先完成、随后清盘、最后重置判脏簿记(含删除失败路径)。 + * 删除与写盘共用同一条串行链——若清盘即重置,in-flight 写入完成后会重新填充 + * 缓存,删除后的首个写入只补写脏分片,而 meta 仍列出全部分片,重启即缺失。 + * + * 失败语义:共享写链永不 reject(后续写入不被删除失败连坐),但返回给调用方 + * 的 Promise 在删除失败时如实 reject——「清空所有数据」流据此跳过状态重置与 + * reload,而不是把残留快照当作已删除导致数据在重启后复活。 + * + * 注意:zustand v4 的 persist.clearStorage() 不回传 removeItem 的 Promise, + * 需要等待删除真正落盘的调用方应直接调用并 await 本函数。 + */ +const removePersistedSnapshot = (name: string): Promise => { + latestPersistName = null; + latestPersistValue = null; + persistWriteVersion++; + cancelPendingPersistTasks(); + const removal = writeChain + .catch(() => undefined) + .then(() => indexedDBStorage.removeItem(name)); + writeChain = removal + .catch((error: unknown) => { + logger.errorFromError('store.persist', 'Failed to remove persisted state snapshot', error); + }) + .then(() => { + lastWrittenShards = null; + committedShards.clear(); + legacySnapshotHydrated = false; + hydratedIncomplete = false; + }); + return removal.then(() => writeChain); +}; + // Create a debounced storage to avoid frequent JSON.stringify calls on large state objects // which causes V8 JIT assertion failures (EXC_BREAKPOINT) on macOS ARM64. +// +// Since the sharded layout, each flush only stringifies and rewrites the shards whose +// persisted fields actually changed (reference-dirty check) — a trivial UI change now +// costs a few KB instead of re-serializing the whole ~100MB snapshot. const debouncedPersistStorage: PersistStorage = { getItem: async (name) => { - const str = await indexedDBStorage.getItem(name); - if (!str) return null; - try { - return JSON.parse(str); - } catch { - return null; - } + // 每次水合重新评估读取完整性;读取失败会在 readPersistedSnapshot 内置位, + // 使后续写入被拒绝,直到出现一次完整读取。 + hydratedIncomplete = false; + return await readPersistedSnapshot(name); }, setItem: (name: string, value: StorageValue) => { registerPersistFlushListeners(); @@ -136,19 +531,11 @@ const debouncedPersistStorage: PersistStorage = { persistTimeoutId = null; persistIdleTaskId = scheduleIdleTask(() => { persistIdleTaskId = null; - writePersistSnapshot(name, value, scheduledVersion, 'idle'); + void enqueueShardedWrite(name, value, scheduledVersion, 'idle'); }); }, 1000); }, - removeItem: (name) => { - latestPersistName = null; - latestPersistValue = null; - persistWriteVersion++; - cancelPendingPersistTasks(); - void Promise.resolve(indexedDBStorage.removeItem(name)).catch((error: unknown) => { - logger.errorFromError('store.persist', 'Failed to remove persisted state snapshot', error); - }); - } + removeItem: (name) => removePersistedSnapshot(name), }; -export { debouncedPersistStorage }; +export { debouncedPersistStorage, flushPendingPersistSnapshot, removePersistedSnapshot }; diff --git a/src/store/useAppStore.test.ts b/src/store/useAppStore.test.ts index 63f6176d2..bf5b02d4f 100644 --- a/src/store/useAppStore.test.ts +++ b/src/store/useAppStore.test.ts @@ -1168,6 +1168,34 @@ describe('useAppStore persisted-state historical fixtures', () => { }); }); + it('keeps persisted heavy-field array references when no release backfill is needed', () => { + // 分片持久化按引用判脏:hydration 规整化不得在无回填需求时重建重数据数组, + // 否则每次启动后都会触发一次全量重写。 + const repositories = [createRepository(1, { has_fetched_releases: true }), createRepository(2)]; + const gists = [{ id: 'gist-1' }]; + const releases = [ + makeRelease(11, { repository: { id: 1, full_name: 'owner/repo-1', name: 'repo-1' } }), + ]; + const normalized = normalizePersistedState( + buildPersistedSnapshot({ repositories, gists, releases }), + useAppStore.getInitialState(), + ); + + expect(normalized.repositories).toBe(repositories); + expect(normalized.gists).toBe(gists); + expect(normalized.releases).toBe(releases); + + // 存在需要回填的仓库时,回填行为保持不变 + const needsBackfill = normalizePersistedState( + buildPersistedSnapshot({ repositories: [createRepository(1)], releases }), + useAppStore.getInitialState(), + ); + expect(needsBackfill.repositories?.[0]).toMatchObject({ + has_fetched_releases: true, + last_release_fetch_time: '2026-01-01T00:00:00.000Z', + }); + }); + it('preserves a historical MCP token while filling host and port defaults during migration and hydration', async () => { const migrated = await migrateSnapshot(buildPersistedSnapshot({ mcpConfig: { enabled: true, token: 'mcp-historical-token' }, diff --git a/src/utils/trendingSnapshots.ts b/src/utils/trendingSnapshots.ts index 39435c2e3..12d71a730 100644 --- a/src/utils/trendingSnapshots.ts +++ b/src/utils/trendingSnapshots.ts @@ -98,7 +98,33 @@ export const normalizeTrendingSnapshots = (value: unknown): TrendingSnapshot[] = const snapshots = value .map(normalizeTrendingSnapshot) .filter((snapshot): snapshot is TrendingSnapshot => snapshot !== null); - return pruneTrendingSnapshots(snapshots); + const pruned = pruneTrendingSnapshots(snapshots); + // 内容等价时保留原数组引用:分片持久化按引用判脏,规整化若总是重建数组, + // 每次启动都会把 trendingSnapshots 分片误判为已变更而全量重写。 + if (pruned.length === value.length + && pruned.every((snapshot, index) => trendingSnapshotContentEqual(snapshot, value[index]))) { + return value as TrendingSnapshot[]; + } + return pruned; +}; + +/** 已知四个字段逐项相等即视为等价(原始对象来自我们自己的 JSON 往返,无额外键)。 */ +const trendingSnapshotContentEqual = (normalized: TrendingSnapshot, raw: unknown): boolean => { + if (!raw || typeof raw !== 'object') return false; + const record = raw as Record; + if (record.period !== normalized.period) return false; + if (record.platform !== normalized.platform) return false; + if (record.capturedAt !== normalized.capturedAt) return false; + const rawEntries = Array.isArray(record.entries) ? record.entries : null; + if (!rawEntries || rawEntries.length !== normalized.entries.length) return false; + return normalized.entries.every((entry, index) => { + const rawEntry = rawEntries[index]; + if (!rawEntry || typeof rawEntry !== 'object') return false; + const candidate = rawEntry as Record; + return candidate.repositoryFullName === entry.repositoryFullName + && candidate.rank === entry.rank + && candidate.stars === entry.stars; + }); }; /** 去掉超期快照,并按桶裁剪到上限(每桶保留最新 N 份)。 */