import { createFailure } from "../../contracts/errors.js"; import { getStorageDefinition } from "../../contracts/storage-keys.js"; /** * @param {{ * localStorage?: Storage, * sessionStorage?: Storage, * now?: () => number * }} [dependencies] * @returns {import("../../application/ports/storage-port.js").StoragePort} */ export function createBrowserStorageAdapter(dependencies = {}) { const memory = new Map(); const now = dependencies.now ?? Date.now; /** @param {string} name */ function backendFor(name) { if (name === "localStorage") return dependencies.localStorage; if (name === "sessionStorage") return dependencies.sessionStorage; return undefined; } return Object.freeze({ read(logicalName) { let definition; try { definition = getStorageDefinition(logicalName); } catch { return unavailable("read", logicalName); } const backend = backendFor(definition.backend); try { const raw = backend?.getItem(definition.physicalKey); if (raw === null || raw === undefined) { return { ok: true, value: memory.get(definition.physicalKey) }; } const envelope = JSON.parse(raw); if ( !envelope || typeof envelope !== "object" || envelope.schemaVersion !== definition.schemaVersion ) { backend?.removeItem(definition.physicalKey); return { ok: true, value: undefined }; } if (typeof envelope.expiresAt === "number" && envelope.expiresAt <= now()) { backend?.removeItem(definition.physicalKey); return { ok: true, value: undefined }; } return { ok: true, value: structuredClone(envelope.value) }; } catch { return unavailable("read", logicalName); } }, write(logicalName, value) { let definition; try { definition = getStorageDefinition(logicalName); } catch { return unavailable("write", logicalName); } const expiresAt = typeof definition.ttl === "number" ? now() + definition.ttl : null; const envelope = { schemaVersion: definition.schemaVersion, expiresAt, value: structuredClone(value), }; const backend = backendFor(definition.backend); try { if (!backend) throw new DOMException("Storage unavailable", "SecurityError"); backend.setItem(definition.physicalKey, JSON.stringify(envelope)); return { ok: true }; } catch (error) { const quota = error instanceof DOMException && ["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(error.name); if (definition.quotaFallback === "memory") { memory.set(definition.physicalKey, structuredClone(value)); return { ok: false, error: storageFailure(quota, "write", logicalName), fallback: "memory", }; } return { ok: false, error: storageFailure(quota, "write", logicalName), fallback: definition.quotaFallback, }; } }, remove(logicalName) { let definition; try { definition = getStorageDefinition(logicalName); } catch { return unavailable("remove", logicalName); } try { backendFor(definition.backend)?.removeItem(definition.physicalKey); memory.delete(definition.physicalKey); return { ok: true }; } catch { return unavailable("remove", logicalName); } }, }); } /** @param {boolean} quota @param {string} phase @param {string} logicalName */ function storageFailure(quota, phase, logicalName) { return createFailure( quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE", "STORAGE", 0, { code: `${logicalName}_${phase.toUpperCase()}_${ quota ? "QUOTA_EXCEEDED" : "UNAVAILABLE" }`, }, ); } /** @param {string} phase @param {string} logicalName */ function unavailable(phase, logicalName) { return { ok: /** @type {false} */ (false), error: storageFailure(false, phase, logicalName), }; }