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,
}
}