chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user