179 lines
5.4 KiB
JavaScript
179 lines
5.4 KiB
JavaScript
import { createFailure } from "../../contracts/errors.js";
|
|
import { getStorageDefinition } from "../../contracts/storage-keys.js";
|
|
|
|
/**
|
|
* @param {{
|
|
* localStorage?: Storage,
|
|
* sessionStorage?: Storage,
|
|
* now?: () => number,
|
|
* diagnostics?: import("../../application/ports/diagnostics-port.js").DiagnosticsPort
|
|
* }} [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, dependencies.diagnostics);
|
|
}
|
|
|
|
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, dependencies.diagnostics);
|
|
}
|
|
},
|
|
|
|
write(logicalName, value) {
|
|
let definition;
|
|
try {
|
|
definition = getStorageDefinition(logicalName);
|
|
} catch {
|
|
return unavailable("write", logicalName, dependencies.diagnostics);
|
|
}
|
|
|
|
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));
|
|
recordStorageFailure(
|
|
dependencies.diagnostics,
|
|
"write",
|
|
logicalName,
|
|
quota,
|
|
);
|
|
return {
|
|
ok: false,
|
|
error: storageFailure(quota, "write", logicalName),
|
|
fallback: "memory",
|
|
};
|
|
}
|
|
recordStorageFailure(
|
|
dependencies.diagnostics,
|
|
"write",
|
|
logicalName,
|
|
quota,
|
|
);
|
|
return {
|
|
ok: false,
|
|
error: storageFailure(quota, "write", logicalName),
|
|
fallback: definition.quotaFallback,
|
|
};
|
|
}
|
|
},
|
|
|
|
remove(logicalName) {
|
|
let definition;
|
|
try {
|
|
definition = getStorageDefinition(logicalName);
|
|
} catch {
|
|
return unavailable("remove", logicalName, dependencies.diagnostics);
|
|
}
|
|
try {
|
|
backendFor(definition.backend)?.removeItem(definition.physicalKey);
|
|
memory.delete(definition.physicalKey);
|
|
return { ok: true };
|
|
} catch {
|
|
return unavailable("remove", logicalName, dependencies.diagnostics);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/** @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
|
|
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
|
|
*/
|
|
function unavailable(phase, logicalName, diagnostics) {
|
|
recordStorageFailure(diagnostics, phase, logicalName, false);
|
|
return {
|
|
ok: /** @type {false} */ (false),
|
|
error: storageFailure(false, phase, logicalName),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {import("../../application/ports/diagnostics-port.js").DiagnosticsPort | undefined} diagnostics
|
|
* @param {string} phase
|
|
* @param {string} logicalName
|
|
* @param {boolean} quota
|
|
*/
|
|
function recordStorageFailure(diagnostics, phase, logicalName, quota) {
|
|
try {
|
|
diagnostics?.record({
|
|
level: "warn",
|
|
eventId: "storage.operation.failed",
|
|
context: {
|
|
operation: `${phase}:${logicalName}`,
|
|
error_kind: quota
|
|
? "STORAGE_QUOTA_EXCEEDED"
|
|
: "STORAGE_UNAVAILABLE",
|
|
},
|
|
});
|
|
} catch {
|
|
// Storage behavior remains independent from diagnostics.
|
|
}
|
|
}
|