138 lines
4.1 KiB
JavaScript
138 lines
4.1 KiB
JavaScript
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),
|
|
};
|
|
}
|