diff --git a/src/lib/bulk/__tests__/bulkHistory.test.ts b/src/lib/bulk/__tests__/bulkHistory.test.ts new file mode 100644 index 00000000..e0b34627 --- /dev/null +++ b/src/lib/bulk/__tests__/bulkHistory.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + useBulkHistory, + bulkHistoryPersistence, + DEFAULT_HISTORY_PAGE_SIZE, + MAX_HISTORY_SIZE, + MAX_PERSISTED_HISTORY_SIZE, + paginateBulkHistory, + type BulkHistoryEntry, + type UseBulkHistoryResult, +} from '../bulkHistory'; + +function storeFor(history: ReturnType>) { + return history as unknown as { getState: () => UseBulkHistoryResult }; +} + +function makeEntry(id: string): Partial> { + return { + operation: 'create', + snapshot: [id], + itemCount: 1, + description: id, + }; +} + +function pushMany(history: ReturnType>, count: number) { + const store = storeFor(history); + for (let i = 0; i < count; i += 1) { + store.getState().push(makeEntry(`item-${i}`) as any); + } +} + +beforeEach(() => { + localStorage.clear(); +}); + +describe('paginateBulkHistory', () => { + const entries = Array.from({ length: 25 }, (_, index) => ({ + ...makeEntry(`e-${index}`), + id: String(index), + timestamp: index, + })) as BulkHistoryEntry[]; + + it('returns the most recent entries first on page 1', () => { + const page = paginateBulkHistory(entries, 1, 10); + expect(page.entries.map((e) => e.id)).toEqual([ + '24', + '23', + '22', + '21', + '20', + '19', + '18', + '17', + '16', + '15', + ]); + expect(page.total).toBe(25); + expect(page.totalPages).toBe(3); + expect(page.hasMore).toBe(true); + expect(page.hasPrevious).toBe(false); + }); + + it('returns the correct middle page', () => { + const page = paginateBulkHistory(entries, 2, 10); + expect(page.entries.map((e) => e.id)).toEqual([ + '14', + '13', + '12', + '11', + '10', + '9', + '8', + '7', + '6', + '5', + ]); + expect(page.hasMore).toBe(true); + expect(page.hasPrevious).toBe(true); + }); + + it('clamps out-of-range pages', () => { + const page = paginateBulkHistory(entries, 99, 10); + expect(page.page).toBe(3); + expect(page.entries.map((e) => e.id)).toEqual(['4', '3', '2', '1', '0']); + expect(page.hasMore).toBe(false); + }); + + it('handles an empty history', () => { + const page = paginateBulkHistory([], 1, 10); + expect(page.entries).toEqual([]); + expect(page.total).toBe(0); + expect(page.totalPages).toBe(1); + expect(page.hasMore).toBe(false); + }); +}); + +describe('useBulkHistory paging', () => { + it('exposes paginated history (most recent first)', () => { + const store = storeFor(useBulkHistory()); + pushMany(store, 12); + const page = store.getState().getHistoryPage(1, DEFAULT_HISTORY_PAGE_SIZE); + expect(page.entries.length).toBe(12); + expect(page.entries[0].description).toBe('item-11'); + expect(page.total).toBe(12); + }); +}); + +describe('useBulkHistory size caps', () => { + it('keeps the in-memory history within MAX_HISTORY_SIZE after push', () => { + const store = storeFor(useBulkHistory()); + pushMany(store, MAX_HISTORY_SIZE + 20); + expect(store.getState().history.length).toBe(MAX_HISTORY_SIZE); + }); + + it('keeps history within MAX_HISTORY_SIZE across redo cycles', () => { + const store = storeFor(useBulkHistory()); + pushMany(store, MAX_HISTORY_SIZE); + for (let i = 0; i < MAX_HISTORY_SIZE - 5; i += 1) { + store.getState().undo(); + } + for (let i = 0; i < MAX_HISTORY_SIZE - 5; i += 1) { + store.getState().redo(); + } + expect(store.getState().history.length).toBeLessThanOrEqual( + MAX_HISTORY_SIZE, + ); + }); +}); + +describe('bulkHistoryPersistence', () => { + it('caps the persisted history at MAX_PERSISTED_HISTORY_SIZE', () => { + const history = Array.from({ length: MAX_PERSISTED_HISTORY_SIZE + 50 }, (_, i) => ({ + id: String(i), + operation: 'create', + timestamp: i, + snapshot: [i], + itemCount: 1, + })); + bulkHistoryPersistence.setItem( + 'bulk-history-storage', + JSON.stringify({ state: { history, redoStack: [] } }), + ); + const stored = JSON.parse( + localStorage.getItem('bulk-history-storage') as string, + ); + expect(stored.state.history.length).toBe(MAX_PERSISTED_HISTORY_SIZE); + expect(stored.state.history[0].id).toBe( + String(MAX_PERSISTED_HISTORY_SIZE + 50 - MAX_PERSISTED_HISTORY_SIZE), + ); + }); + + it('drops the persisted redo stack on read', () => { + bulkHistoryPersistence.setItem( + 'bulk-history-storage', + JSON.stringify({ + state: { history: [makeEntry('a')], redoStack: [makeEntry('b')] }, + }), + ); + const raw = bulkHistoryPersistence.getItem('bulk-history-storage'); + const parsed = JSON.parse(raw as string); + expect(parsed.state.redoStack).toEqual([]); + expect(parsed.state.history).toHaveLength(1); + }); +}); \ No newline at end of file diff --git a/src/lib/bulk/bulkHistory.ts b/src/lib/bulk/bulkHistory.ts index cc7526b5..30a0db0e 100644 --- a/src/lib/bulk/bulkHistory.ts +++ b/src/lib/bulk/bulkHistory.ts @@ -36,9 +36,100 @@ export interface UseBulkHistoryResult { clear: () => void; /** Get current history index */ getCurrentIndex: () => number; + /** Paginated view of the history (most recent first). */ + getHistoryPage: (page?: number, pageSize?: number) => BulkHistoryPage; } -const MAX_HISTORY_SIZE = 50; +/** A single page of history entries with paging metadata. */ +export interface BulkHistoryPage { + /** Entries for the requested page (most recent first). */ + entries: BulkHistoryEntry[]; + page: number; + pageSize: number; + total: number; + totalPages: number; + hasMore: boolean; + hasPrevious: boolean; +} + +export const MAX_HISTORY_SIZE = 50; + +/** Maximum number of history entries persisted to storage. */ +export const MAX_PERSISTED_HISTORY_SIZE = 100; + +/** Default page size returned by `getHistoryPage`. */ +export const DEFAULT_HISTORY_PAGE_SIZE = 20; + +/** + * Returns a page of history entries, most recent first (history is stored + * oldest-first so a full reverse is applied per page). Invalid pages are + * clamped to the nearest valid page. + */ +export function paginateBulkHistory( + entries: BulkHistoryEntry[], + page: number, + pageSize: number, +): BulkHistoryPage { + const safePage = Math.max(1, Math.floor(page) || 1); + const safePageSize = Math.max(1, Math.floor(pageSize) || DEFAULT_HISTORY_PAGE_SIZE); + const total = entries.length; + const totalPages = Math.max(1, Math.ceil(total / safePageSize)); + const clampedPage = Math.min(safePage, totalPages); + const start = (clampedPage - 1) * safePageSize; + const mostRecentFirst = entries.slice().reverse(); + const sliced = mostRecentFirst.slice(start, start + safePageSize); + + return { + entries: sliced, + page: clampedPage, + pageSize: safePageSize, + total, + totalPages, + hasMore: clampedPage < totalPages, + hasPrevious: clampedPage > 1, + }; +} + +/** + * localStorage adapter for bulk history. Enforces an upper bound on the number + * of persisted entries so the stored payload cannot grow without limit. + */ +export const bulkHistoryPersistence = { + getItem: (name: string) => { + const str = localStorage.getItem(name); + if (!str) return null; + try { + const parsed = JSON.parse(str); + // Don't persist redo stack + parsed.state = { + ...parsed.state, + redoStack: [], + }; + return JSON.stringify(parsed); + } catch { + return null; + } + }, + setItem: (name: string, value: string) => { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed.state?.history)) { + if (parsed.state.history.length > MAX_PERSISTED_HISTORY_SIZE) { + parsed.state.history = parsed.state.history.slice( + -MAX_PERSISTED_HISTORY_SIZE, + ); + } + value = JSON.stringify(parsed); + } + } catch { + // Leave the original value untouched when it is not JSON. + } + localStorage.setItem(name, value); + }, + removeItem: (name: string) => { + localStorage.removeItem(name); + }, +}; /** * Hook for managing undo/redo stack for bulk operations. @@ -106,6 +197,12 @@ export function useBulkHistory(): UseBulkHistoryResult { const [nextEntry, ...remainingRedo] = state.redoStack; const newHistory = [...state.history, nextEntry]; + // Keep the redo path bounded as well so the history can never grow + // beyond the in-memory cap. + if (newHistory.length > MAX_HISTORY_SIZE) { + newHistory.shift(); + } + return { history: newHistory, redoStack: remainingRedo, @@ -123,32 +220,17 @@ export function useBulkHistory(): UseBulkHistoryResult { }), getCurrentIndex: () => get().history.length - 1, + + getHistoryPage: (page?: number, pageSize?: number) => + paginateBulkHistory( + get().history, + page ?? 1, + pageSize ?? DEFAULT_HISTORY_PAGE_SIZE, + ), }), { name: 'bulk-history-storage', - storage: { - getItem: (name) => { - const str = localStorage.getItem(name); - if (!str) return null; - try { - const parsed = JSON.parse(str); - // Don't persist redo stack - parsed.state = { - ...parsed.state, - redoStack: [], - }; - return JSON.stringify(parsed); - } catch { - return null; - } - }, - setItem: (name, value) => { - localStorage.setItem(name, value); - }, - removeItem: (name) => { - localStorage.removeItem(name); - }, - }, + storage: bulkHistoryPersistence, }, ), ); diff --git a/src/store/persistenceLayer.ts b/src/store/persistenceLayer.ts index 9b66fd8d..00fbaffb 100644 --- a/src/store/persistenceLayer.ts +++ b/src/store/persistenceLayer.ts @@ -75,3 +75,74 @@ export const persistenceLayer = { await this.setItem(name, JSON.stringify(value)); }, }; + +/** A single page of a persisted JSON array with paging metadata. */ +export interface PersistedPage { + entries: T[]; + page: number; + pageSize: number; + total: number; + totalPages: number; + hasMore: boolean; +} + +/** + * Returns a page of a JSON array previously stored under `name`. Indices are + * treated as ordered most-recent-first by the caller; this helper only slices. + * Returns `null` when no value (or a non-array value) is stored. + */ +export async function paginatePersistedJSON( + name: string, + page: number, + pageSize: number, +): Promise | null> { + const raw = await persistenceLayer.getJSON(name); + if (!Array.isArray(raw)) return null; + const entries = raw as T[]; + const safePage = Math.max(1, Math.floor(page) || 1); + const safePageSize = Math.max(1, Math.floor(pageSize) || 20); + const total = entries.length; + const totalPages = Math.max(1, Math.ceil(total / safePageSize)); + const clampedPage = Math.min(safePage, totalPages); + const start = (clampedPage - 1) * safePageSize; + return { + entries: entries.slice(start, start + safePageSize), + page: clampedPage, + pageSize: safePageSize, + total, + totalPages, + hasMore: clampedPage < totalPages, + }; +} + +/** + * Returns a copy of `value` keeping only the given top-level keys. Used to + * prune unknown/stale slices from a persisted state when the schema version + * has changed. + */ +export function pruneUnknownKeys>( + value: T, + allowedKeys: readonly string[], +): Record { + const allowed = new Set(allowedKeys); + const pruned: Record = {}; + for (const key of Object.keys(value)) { + if (allowed.has(key)) pruned[key] = value[key]; + } + return pruned; +} + +/** + * Reads the schema `version` recorded inside a persisted zustand payload + * (shape `{ state, version }`). Returns `undefined` when the payload is not + * versioned JSON. + */ +export function persistedStateVersion(raw: string | null): number | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === 'number' ? parsed.version : undefined; + } catch { + return undefined; + } +}