392 lines
11 KiB
TypeScript
392 lines
11 KiB
TypeScript
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<string, string>();
|
|
const suppressedPersistentValues = new Set<string>();
|
|
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<StorageMutationResult, { ok: false }> {
|
|
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<StorageMutationResult, { ok: false }> {
|
|
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<StorageMutationResult, { ok: false }> {
|
|
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";
|
|
}
|