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
82 changes: 82 additions & 0 deletions src/lib/stores/resilient-local-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { createJSONStorage, type StateStorage } from 'zustand/middleware';

/** Lists the server hydrates again. Drafts and the result on screen stay. */
const BULKY_LISTS = [
'sourceLibrary',
'sourceFolders',
'savedCases',
'savedAttacks',
'savedBrainstorms',
'savedFactoids',
'savedChats',
'savedFlowcharts',
'savedCrossfires',
];

function isQuotaError(error: unknown): boolean {
if (!(error instanceof DOMException)) return false;
return error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED' || error.code === 22;
}

function readState(serialized: string): { envelope: { state?: Record<string, unknown> }; state: Record<string, unknown> } | null {
try {
const envelope = JSON.parse(serialized) as { state?: Record<string, unknown> };
if (!envelope.state || typeof envelope.state !== 'object') return null;
return { envelope, state: envelope.state };
} catch {
return null;
}
}

/** Drop source text first. Titles and ids stay, so the library does not flash empty. */
export function withoutSourceText(serialized: string): string | null {
const parsed = readState(serialized);
if (!parsed || !Array.isArray(parsed.state.sourceLibrary)) return null;
parsed.state.sourceLibrary = parsed.state.sourceLibrary.map((item) => {
if (!item || typeof item !== 'object') return item;
return { ...(item as Record<string, unknown>), content: '' };
});
return JSON.stringify(parsed.envelope);
}

/** Last try: forget the bulky copies and keep the open draft. */
export function withoutBulkyLists(serialized: string): string | null {
const parsed = readState(serialized);
if (!parsed) return null;
for (const key of BULKY_LISTS) delete parsed.state[key];
return JSON.stringify(parsed.envelope);
}

/**
* Zustand persist lets a full localStorage throw out of a tool page. On a quota
* error, retry without source text, then without the bulky lists, then keep
* going in memory.
*/
export function resilientSetItem(storage: Pick<Storage, 'setItem'>, key: string, value: string): void {
const attempts = [value, withoutSourceText(value), withoutBulkyLists(value)]
.filter((attempt): attempt is string => typeof attempt === 'string');
const seen = new Set<string>();
let lastQuota: unknown;
for (const attempt of attempts) {
if (seen.has(attempt)) continue;
seen.add(attempt);
try {
storage.setItem(key, attempt);
return;
} catch (error) {
if (!isQuotaError(error)) throw error;
lastQuota = error;
}
}
console.warn('[SessionStore] Local storage is full; this change stays in memory.', lastQuota);
}

const resilientStorage: StateStorage = {
getItem: (name) => localStorage.getItem(name),
setItem: (name, value) => resilientSetItem(localStorage, name, value),
removeItem: (name) => localStorage.removeItem(name),
};

export function resilientLocalStorage() {
return createJSONStorage(() => resilientStorage);
}
5 changes: 3 additions & 2 deletions src/lib/stores/session-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { persist } from 'zustand/middleware';
import { resilientLocalStorage } from '@/lib/stores/resilient-local-storage';
import type { Node, Edge } from 'reactflow';
import type { GenerateOutput, GenerateInput } from '../ai/schemas/generate';
import type { AttackOutput, AttackInput } from '../ai/schemas/attack';
Expand Down Expand Up @@ -1285,7 +1286,7 @@ export const useSessionStore = create<SessionState>()(
}),
{
name: 'debate-trainer-storage',
storage: createJSONStorage(() => localStorage),
storage: resilientLocalStorage(),
// v1 scopes saved work and sources to debates. Drop lists persisted by
// older clients: the server replaces them on hydrate, and keeping them
// would resurrect purged legacy rows and debate-less sources.
Expand Down
55 changes: 55 additions & 0 deletions src/test/stores/resilient-local-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { resilientSetItem, withoutBulkyLists, withoutSourceText } from '@/lib/stores/resilient-local-storage';

const saved = JSON.stringify({
state: {
sourceLibrary: [{ id: 'src-1', title: 'Buses', content: 'a very long article' }],
savedCases: [{ id: 'case-1', data: { contentions: ['cost'] } }],
generateForm: { motion: 'Free buses' },
generateResult: { title: 'Open case' },
},
version: 1,
});

describe('resilient local storage', () => {
it('removes source text before it removes saved lists', () => {
const stripped = withoutSourceText(saved);
expect(stripped).toContain('"title":"Buses"');
expect(stripped).not.toContain('a very long article');
expect(stripped).toContain('Free buses');

const emptied = withoutBulkyLists(saved)!;
const parsed = JSON.parse(emptied) as { state: Record<string, unknown> };
expect(parsed.state.sourceLibrary).toBeUndefined();
expect(parsed.state.savedCases).toBeUndefined();
expect(parsed.state.generateForm).toEqual({ motion: 'Free buses' });
expect(parsed.state.generateResult).toEqual({ title: 'Open case' });
});

it('retries a quota failure and does not throw when every copy is still too big', () => {
const writes: string[] = [];
const storage = {
setItem: (key: string, value: string) => {
writes.push(value);
if (value.includes('a very long article') || value.includes('savedCases')) {
throw new DOMException('quota', 'QuotaExceededError');
}
},
};
resilientSetItem(storage, 'debate-trainer-storage', saved);
expect(writes).toHaveLength(3);
expect(writes[1]).not.toContain('a very long article');
expect(writes[2]).not.toContain('savedCases');
expect(writes[2]).toContain('Free buses');

const alwaysFull = {
setItem: () => { throw new DOMException('quota', 'QuotaExceededError'); },
};
expect(() => resilientSetItem(alwaysFull, 'debate-trainer-storage', saved)).not.toThrow();
});

it('still throws errors that are not a full disk', () => {
const storage = { setItem: () => { throw new Error('blocked'); } };
expect(() => resilientSetItem(storage, 'debate-trainer-storage', saved)).toThrow('blocked');
});
});
Loading