Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/lib/bulk/__tests__/bulkHistory.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useBulkHistory<string>>) {
return history as unknown as { getState: () => UseBulkHistoryResult<string> };
}

function makeEntry(id: string): Partial<BulkHistoryEntry<string>> {
return {
operation: 'create',
snapshot: [id],
itemCount: 1,
description: id,
};
}

function pushMany(history: ReturnType<typeof useBulkHistory<string>>, 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<string>[];

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<string>());
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<string>());
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<string>());
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);
});
});
130 changes: 106 additions & 24 deletions src/lib/bulk/bulkHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,100 @@ export interface UseBulkHistoryResult<T> {
clear: () => void;
/** Get current history index */
getCurrentIndex: () => number;
/** Paginated view of the history (most recent first). */
getHistoryPage: (page?: number, pageSize?: number) => BulkHistoryPage<T>;
}

const MAX_HISTORY_SIZE = 50;
/** A single page of history entries with paging metadata. */
export interface BulkHistoryPage<T> {
/** Entries for the requested page (most recent first). */
entries: BulkHistoryEntry<T>[];
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<T>(
entries: BulkHistoryEntry<T>[],
page: number,
pageSize: number,
): BulkHistoryPage<T> {
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.
Expand Down Expand Up @@ -106,6 +197,12 @@ export function useBulkHistory<T>(): UseBulkHistoryResult<T> {
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,
Expand All @@ -123,32 +220,17 @@ export function useBulkHistory<T>(): UseBulkHistoryResult<T> {
}),

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,
},
),
);
Expand Down
71 changes: 71 additions & 0 deletions src/store/persistenceLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
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<T>(
name: string,
page: number,
pageSize: number,
): Promise<PersistedPage<T> | null> {
const raw = await persistenceLayer.getJSON<unknown>(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<T extends Record<string, unknown>>(
value: T,
allowedKeys: readonly string[],
): Record<string, unknown> {
const allowed = new Set(allowedKeys);
const pruned: Record<string, unknown> = {};
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;
}
}
Loading