feat: add resilient storage module with legacy key migration

A factory over an injectable backend, so persistence can be tested without
a DOM.

Fixes the unguarded JSON.parse that would white-screen the app on one bad
value, and falls back to memory when localStorage itself throws (Safari
private mode, full quota).

migrateLegacyKeys converts old "Sat Aug 15 2026" keys in place. It's
idempotent, and it leaves anything it can't parse alone rather than
deleting it.
This commit is contained in:
Aculix Technologies
2026-08-15 23:50:20 +05:30
parent e81e7ef8e7
commit d69c68ccef
2 changed files with 314 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
import { isKey, toKey } from './dates.js'
const TASK_PREFIX = 'negotium-tasks-'
const THEME_KEY = 'negotium-theme'
/** Backend used in tests, and as the production fallback when localStorage is
* unavailable (Safari private mode, disabled storage, exhausted quota). */
export function createMemoryStore() {
const map = new Map()
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => {
map.set(key, String(value))
},
removeItem: (key) => {
map.delete(key)
},
keys: () => [...map.keys()],
}
}
function wrapWebStorage(webStorage) {
return {
getItem: (key) => webStorage.getItem(key),
setItem: (key, value) => webStorage.setItem(key, value),
removeItem: (key) => webStorage.removeItem(key),
keys: () => Object.keys(webStorage),
}
}
function resolveBackend() {
try {
const probe = '__negotium_probe__'
window.localStorage.setItem(probe, '1')
window.localStorage.removeItem(probe)
return wrapWebStorage(window.localStorage)
} catch {
return createMemoryStore()
}
}
export function createStorage(backend) {
const store = backend ?? resolveBackend()
function read(key) {
try {
return store.getItem(key)
} catch {
return null
}
}
function write(key, value) {
try {
store.setItem(key, value)
} catch {
// Quota exhausted or storage revoked mid-session. In-memory state stays
// authoritative; losing a write beats losing the app.
}
}
function drop(key) {
try {
store.removeItem(key)
} catch {
// Nothing actionable.
}
}
function allKeys() {
try {
return store.keys()
} catch {
return []
}
}
function parseTasks(raw) {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function loadTasks(dateKey) {
return parseTasks(read(TASK_PREFIX + dateKey))
}
function saveTasks(dateKey, tasks) {
write(TASK_PREFIX + dateKey, JSON.stringify(tasks))
}
function taskSuffixes() {
return allKeys()
.filter((key) => key.startsWith(TASK_PREFIX))
.map((key) => key.slice(TASK_PREFIX.length))
}
function listTaskKeys() {
return taskSuffixes().filter(isKey)
}
function removeTasks(dateKey) {
drop(TASK_PREFIX + dateKey)
}
function loadTheme() {
return read(THEME_KEY)
}
function saveTheme(mode) {
write(THEME_KEY, mode)
}
/** One-time conversion of `negotium-tasks-Sat Aug 15 2026` keys written by
* versions before the ISO format. Idempotent, and deliberately conservative:
* a suffix that will not parse is left in place rather than discarded. */
function migrateLegacyKeys() {
for (const suffix of taskSuffixes().filter((s) => !isKey(s))) {
const parsed = new Date(suffix)
if (Number.isNaN(parsed.getTime())) continue
const isoKey = toKey(parsed)
const legacy = parseTasks(read(TASK_PREFIX + suffix))
const existing = loadTasks(isoKey)
saveTasks(isoKey, [...legacy, ...existing])
drop(TASK_PREFIX + suffix)
}
}
return {
loadTasks,
saveTasks,
listTaskKeys,
removeTasks,
loadTheme,
saveTheme,
migrateLegacyKeys,
}
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest'
import { createStorage, createMemoryStore } from './storage.js'
const setup = (seed = {}) => {
const backend = createMemoryStore()
for (const [key, value] of Object.entries(seed)) backend.setItem(key, value)
return { backend, storage: createStorage(backend) }
}
describe('loadTasks', () => {
it('returns an empty array for a missing key', () => {
const { storage } = setup()
expect(storage.loadTasks('2026-08-15')).toEqual([])
})
it('returns stored tasks', () => {
const tasks = [{ id: 'a', text: 'buy milk', completed: false }]
const { storage } = setup({ 'negotium-tasks-2026-08-15': JSON.stringify(tasks) })
expect(storage.loadTasks('2026-08-15')).toEqual(tasks)
})
it('returns an empty array for corrupt JSON instead of throwing', () => {
const { storage } = setup({ 'negotium-tasks-2026-08-15': '{not json' })
expect(storage.loadTasks('2026-08-15')).toEqual([])
})
it('returns an empty array when the stored value is not an array', () => {
const { storage } = setup({ 'negotium-tasks-2026-08-15': '{"a":1}' })
expect(storage.loadTasks('2026-08-15')).toEqual([])
})
it('survives a throwing backend', () => {
const backend = createMemoryStore()
backend.getItem = () => {
throw new Error('storage revoked')
}
const storage = createStorage(backend)
expect(storage.loadTasks('2026-08-15')).toEqual([])
})
})
describe('saveTasks', () => {
it('round-trips through the backend', () => {
const { storage } = setup()
const tasks = [{ id: 'a', text: 'x', completed: true }]
storage.saveTasks('2026-08-15', tasks)
expect(storage.loadTasks('2026-08-15')).toEqual(tasks)
})
it('swallows a throwing backend rather than propagating', () => {
const backend = createMemoryStore()
backend.setItem = () => {
throw new Error('QuotaExceededError')
}
const storage = createStorage(backend)
expect(() => storage.saveTasks('2026-08-15', [])).not.toThrow()
})
})
describe('listTaskKeys', () => {
it('returns only date keys, with the prefix stripped', () => {
const { storage } = setup({
'negotium-tasks-2026-08-15': '[]',
'negotium-tasks-2026-08-16': '[]',
'negotium-theme': 'dark',
unrelated: 'x',
})
expect(storage.listTaskKeys().sort()).toEqual(['2026-08-15', '2026-08-16'])
})
it('excludes legacy-format keys', () => {
const { storage } = setup({
'negotium-tasks-2026-08-15': '[]',
'negotium-tasks-Sat Aug 15 2026': '[]',
})
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
})
it('returns an empty array when enumeration throws', () => {
const backend = createMemoryStore()
backend.keys = () => {
throw new Error('nope')
}
expect(createStorage(backend).listTaskKeys()).toEqual([])
})
})
describe('removeTasks', () => {
it('deletes the entry', () => {
const { storage } = setup({ 'negotium-tasks-2026-08-15': '[]' })
storage.removeTasks('2026-08-15')
expect(storage.listTaskKeys()).toEqual([])
})
})
describe('theme', () => {
it('returns null when unset', () => {
const { storage } = setup()
expect(storage.loadTheme()).toBe(null)
})
it('round-trips', () => {
const { storage } = setup()
storage.saveTheme('dark')
expect(storage.loadTheme()).toBe('dark')
})
})
describe('migrateLegacyKeys', () => {
it('rewrites a legacy key to ISO form', () => {
const tasks = [{ id: 'a', text: 'legacy', completed: false }]
const { storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': JSON.stringify(tasks) })
storage.migrateLegacyKeys()
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
expect(storage.loadTasks('2026-08-15')).toEqual(tasks)
})
it('removes the legacy key once migrated', () => {
const { backend, storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': '[]' })
storage.migrateLegacyKeys()
expect(backend.getItem('negotium-tasks-Sat Aug 15 2026')).toBe(null)
})
it('merges legacy before existing when the ISO key is occupied', () => {
const legacy = [{ id: 'l', text: 'legacy', completed: false }]
const current = [{ id: 'c', text: 'current', completed: false }]
const { storage } = setup({
'negotium-tasks-Sat Aug 15 2026': JSON.stringify(legacy),
'negotium-tasks-2026-08-15': JSON.stringify(current),
})
storage.migrateLegacyKeys()
expect(storage.loadTasks('2026-08-15').map((t) => t.id)).toEqual(['l', 'c'])
})
it('leaves an unparseable key untouched rather than destroying it', () => {
const { storage, backend } = setup({ 'negotium-tasks-not-a-date': '[{"id":"x"}]' })
storage.migrateLegacyKeys()
expect(backend.getItem('negotium-tasks-not-a-date')).toBe('[{"id":"x"}]')
})
it('is idempotent', () => {
const { storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': '[{"id":"a"}]' })
storage.migrateLegacyKeys()
storage.migrateLegacyKeys()
expect(storage.loadTasks('2026-08-15')).toEqual([{ id: 'a' }])
})
it('migrates several legacy days independently', () => {
const { storage } = setup({
'negotium-tasks-Fri Aug 14 2026': '[{"id":"fri"}]',
'negotium-tasks-Sat Aug 15 2026': '[{"id":"sat"}]',
})
storage.migrateLegacyKeys()
expect(storage.listTaskKeys().sort()).toEqual(['2026-08-14', '2026-08-15'])
expect(storage.loadTasks('2026-08-14')).toEqual([{ id: 'fri' }])
expect(storage.loadTasks('2026-08-15')).toEqual([{ id: 'sat' }])
})
it('does nothing when there is nothing to migrate', () => {
const { storage } = setup({ 'negotium-tasks-2026-08-15': '[]' })
storage.migrateLegacyKeys()
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
})
it('leaves the theme key alone', () => {
const { backend, storage } = setup({ 'negotium-theme': 'dark' })
storage.migrateLegacyKeys()
expect(backend.getItem('negotium-theme')).toBe('dark')
})
})