import { createFailure } from "../../contracts/errors.ts"; import { getStorageDefinition, isStorageValueAllowed, type StorageDefinition, } from "../../contracts/storage-keys.ts"; import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; import type { StorageMutationResult, StoragePort, } from "../../application/ports/storage-port.ts"; import { assertValidBrowserStorageByteLimit, decodeBrowserStorageEnvelope, DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES, encodeBrowserStorageEnvelope, type BrowserStorageCodecFailure, } from "./browser-storage-codec.ts"; export type BrowserStorageDependencies = Readonly<{ localStorage?: Storage; sessionStorage?: Storage; now?: () => number; diagnostics?: DiagnosticsPort; maxSerializedBytes?: number; resolveDefinition?: (logicalName: string) => StorageDefinition; }>; type StorageFailureCause = | "QUOTA_EXCEEDED" | "SIZE_LIMIT_EXCEEDED" | "UNAVAILABLE" | "VALUE_REJECTED"; export function createBrowserStorageAdapter( dependencies: BrowserStorageDependencies = {}, ): StoragePort { const memoryOverlay = new Map(); const suppressedPersistentValues = new Set(); const now = dependencies.now ?? Date.now; const maxSerializedBytes = dependencies.maxSerializedBytes ?? DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES; const resolveDefinition = dependencies.resolveDefinition ?? getStorageDefinition; assertValidBrowserStorageByteLimit(maxSerializedBytes); function backendFor(name: string): Storage | undefined { if (name === "localStorage") return dependencies.localStorage; if (name === "sessionStorage") return dependencies.sessionStorage; return undefined; } function definitionFor( logicalName: string, phase: string, ): | Readonly<{ ok: true; value: StorageDefinition }> | Extract { try { return { ok: true, value: resolveDefinition(logicalName) }; } catch { return unavailable(phase, logicalName, dependencies.diagnostics); } } function currentTime( phase: string, logicalName: string, ): | Readonly<{ ok: true; value: number }> | Extract { try { const value = now(); if (!Number.isSafeInteger(value) || value < 0) { throw new TypeError("Invalid storage clock."); } return { ok: true, value }; } catch { return unavailable(phase, logicalName, dependencies.diagnostics); } } function discardRecord( definition: StorageDefinition, backend: Storage | undefined, ): void { memoryOverlay.delete(definition.physicalKey); suppressedPersistentValues.add(definition.physicalKey); if (!backend) { suppressedPersistentValues.delete(definition.physicalKey); return; } try { backend.removeItem(definition.physicalKey); suppressedPersistentValues.delete(definition.physicalKey); } catch { // Keep the in-memory tombstone so the rejected value is not parsed again. } } function readEnvelope( raw: string, definition: StorageDefinition, backend: Storage | undefined, logicalName: string, ) { const decoded = decodeBrowserStorageEnvelope(raw, maxSerializedBytes); if (!decoded.ok) { recordStorageFailure( dependencies.diagnostics, "discard", logicalName, codecFailureCause(decoded.reason), ); discardRecord(definition, backend); return { ok: true as const, value: undefined }; } const envelope = decoded.value; const expectsExpiry = typeof definition.ttl === "number"; if ( envelope.schemaVersion !== definition.schemaVersion || expectsExpiry !== (envelope.expiresAt !== null) || !isStorageValueAllowed(definition, envelope.value) ) { recordStorageFailure( dependencies.diagnostics, "discard", logicalName, "VALUE_REJECTED", ); discardRecord(definition, backend); return { ok: true as const, value: undefined }; } if (envelope.expiresAt !== null) { const timestamp = currentTime("read", logicalName); if (!timestamp.ok) return timestamp; if (envelope.expiresAt <= timestamp.value) { discardRecord(definition, backend); return { ok: true as const, value: undefined }; } } return { ok: true as const, value: envelope.value }; } return Object.freeze({ read(logicalName) { const selected = definitionFor(logicalName, "read"); if (!selected.ok) return selected; const definition = selected.value; const backend = backendFor(definition.backend); const overlay = memoryOverlay.get(definition.physicalKey); if (overlay !== undefined) { return readEnvelope( overlay, definition, backend, logicalName, ); } if (suppressedPersistentValues.has(definition.physicalKey)) { return { ok: true, value: undefined }; } try { const raw = backend?.getItem(definition.physicalKey); if (raw === null || raw === undefined) { return { ok: true, value: undefined }; } return readEnvelope(raw, definition, backend, logicalName); } catch { return unavailable("read", logicalName, dependencies.diagnostics); } }, write(logicalName, value) { const selected = definitionFor(logicalName, "write"); if (!selected.ok) return selected; const definition = selected.value; if (!isStorageValueAllowed(definition, value)) { recordStorageFailure( dependencies.diagnostics, "write", logicalName, "VALUE_REJECTED", ); return { ok: false, error: storageFailure( "VALUE_REJECTED", "write", logicalName, ), }; } let expiresAt: number | null = null; if (typeof definition.ttl === "number") { const timestamp = currentTime("write", logicalName); if (!timestamp.ok) return timestamp; const expiration = timestamp.value + definition.ttl; if (!Number.isSafeInteger(expiration)) { return unavailable( "write", logicalName, dependencies.diagnostics, ); } expiresAt = expiration; } const encoded = encodeBrowserStorageEnvelope( { schemaVersion: definition.schemaVersion, expiresAt, value, }, maxSerializedBytes, ); if (!encoded.ok) { const cause = codecFailureCause(encoded.reason); recordStorageFailure( dependencies.diagnostics, "write", logicalName, cause, ); return { ok: false, error: storageFailure(cause, "write", logicalName), }; } if (definition.backend === "memory") { memoryOverlay.set(definition.physicalKey, encoded.value); suppressedPersistentValues.delete(definition.physicalKey); return { ok: true }; } const backend = backendFor(definition.backend); try { if (!backend) { throw new DOMException("Storage unavailable", "SecurityError"); } backend.setItem(definition.physicalKey, encoded.value); memoryOverlay.delete(definition.physicalKey); suppressedPersistentValues.delete(definition.physicalKey); return { ok: true }; } catch (error) { const cause: StorageFailureCause = isQuotaError(error) ? "QUOTA_EXCEEDED" : "UNAVAILABLE"; if (definition.quotaFallback === "memory") { memoryOverlay.set(definition.physicalKey, encoded.value); suppressedPersistentValues.delete(definition.physicalKey); recordStorageFailure( dependencies.diagnostics, "write", logicalName, cause, ); return { ok: false, error: storageFailure(cause, "write", logicalName), fallback: "memory", }; } recordStorageFailure( dependencies.diagnostics, "write", logicalName, cause, ); return { ok: false, error: storageFailure(cause, "write", logicalName), fallback: definition.quotaFallback, }; } }, remove(logicalName) { const selected = definitionFor(logicalName, "remove"); if (!selected.ok) return selected; const definition = selected.value; const backend = backendFor(definition.backend); memoryOverlay.delete(definition.physicalKey); suppressedPersistentValues.add(definition.physicalKey); try { backend?.removeItem(definition.physicalKey); suppressedPersistentValues.delete(definition.physicalKey); return { ok: true }; } catch { recordStorageFailure( dependencies.diagnostics, "remove", logicalName, "UNAVAILABLE", ); return { ok: false, error: storageFailure("UNAVAILABLE", "remove", logicalName), }; } }, }); } function codecFailureCause( reason: BrowserStorageCodecFailure, ): StorageFailureCause { return reason === "OVERSIZE" ? "SIZE_LIMIT_EXCEEDED" : "VALUE_REJECTED"; } function isQuotaError(error: unknown): boolean { try { if (!error || typeof error !== "object") return false; const name = (error as Readonly<{ name?: unknown }>).name; return ( typeof name === "string" && ["QuotaExceededError", "NS_ERROR_DOM_QUOTA_REACHED"].includes(name) ); } catch { return false; } } function storageFailure( cause: StorageFailureCause, phase: string, logicalName: string, ) { const quota = cause === "QUOTA_EXCEEDED"; return createFailure( quota ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE", "STORAGE", 0, { code: `${safeLogicalName(logicalName)}_${phase.toUpperCase()}_${cause}`, }, ); } function unavailable( phase: string, logicalName: string, diagnostics: DiagnosticsPort | undefined, ): Extract { recordStorageFailure( diagnostics, phase, logicalName, "UNAVAILABLE", ); return { ok: false, error: storageFailure("UNAVAILABLE", phase, logicalName), }; } function recordStorageFailure( diagnostics: DiagnosticsPort | undefined, phase: string, logicalName: string, cause: StorageFailureCause, ): void { try { diagnostics?.record({ level: "warn", eventId: "storage.operation.failed", context: { operation: `${phase}:${safeLogicalName(logicalName)}`, error_kind: cause === "QUOTA_EXCEEDED" ? "STORAGE_QUOTA_EXCEEDED" : "STORAGE_UNAVAILABLE", }, }); } catch { // Storage behavior remains independent from diagnostics. } } function safeLogicalName(logicalName: string): string { return /^[A-Z][A-Z0-9_]{0,63}$/u.test(logicalName) ? logicalName : "UNKNOWN_KEY"; }