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";
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
export const DEFAULT_BROWSER_STORAGE_MAX_SERIALIZED_BYTES = 16_384;
|
||||
|
||||
const MAX_VALUE_DEPTH = 32;
|
||||
const MAX_VALUE_NODES = 2_048;
|
||||
const FORBIDDEN_RECORD_KEYS = new Set([
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"prototype",
|
||||
]);
|
||||
|
||||
export type BrowserStorageEnvelope = Readonly<{
|
||||
schemaVersion: number;
|
||||
expiresAt: number | null;
|
||||
value: unknown;
|
||||
}>;
|
||||
|
||||
export type BrowserStorageCodecFailure =
|
||||
| "INVALID_VALUE"
|
||||
| "MALFORMED_RECORD"
|
||||
| "OVERSIZE";
|
||||
|
||||
export type BrowserStorageCodecResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; reason: BrowserStorageCodecFailure }>;
|
||||
|
||||
/**
|
||||
* Closed JSON codec for small Web Storage values. It rejects values that JSON
|
||||
* would silently coerce or omit, accessors, exotic prototypes and unsafe
|
||||
* record keys before they can cross the persistence boundary.
|
||||
*/
|
||||
export function encodeBrowserStorageEnvelope(
|
||||
envelope: BrowserStorageEnvelope,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<string> {
|
||||
try {
|
||||
if (!validEnvelopeMetadata(envelope)) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
const valueValidation = validateStorageValue(
|
||||
envelope.value,
|
||||
maxSerializedBytes,
|
||||
);
|
||||
if (!valueValidation.ok) return valueValidation;
|
||||
const raw = JSON.stringify(envelope);
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes
|
||||
) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
return { ok: true, value: raw };
|
||||
} catch {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeBrowserStorageEnvelope(
|
||||
raw: string,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<BrowserStorageEnvelope> {
|
||||
try {
|
||||
if (serializedByteLength(raw, maxSerializedBytes) > maxSerializedBytes) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isExactEnvelope(parsed)) {
|
||||
return { ok: false, reason: "MALFORMED_RECORD" };
|
||||
}
|
||||
const valueValidation = validateStorageValue(
|
||||
parsed.value,
|
||||
maxSerializedBytes,
|
||||
);
|
||||
if (!valueValidation.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
valueValidation.reason === "OVERSIZE"
|
||||
? "OVERSIZE"
|
||||
: "MALFORMED_RECORD",
|
||||
};
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
} catch {
|
||||
return { ok: false, reason: "MALFORMED_RECORD" };
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidBrowserStorageByteLimit(value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 64) {
|
||||
throw new TypeError(
|
||||
"Browser storage serialized byte limit must be a safe integer of at least 64.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validEnvelopeMetadata(envelope: BrowserStorageEnvelope): boolean {
|
||||
return (
|
||||
Boolean(envelope) &&
|
||||
typeof envelope === "object" &&
|
||||
Number.isSafeInteger(envelope.schemaVersion) &&
|
||||
envelope.schemaVersion > 0 &&
|
||||
(envelope.expiresAt === null ||
|
||||
(Number.isSafeInteger(envelope.expiresAt) && envelope.expiresAt >= 0))
|
||||
);
|
||||
}
|
||||
|
||||
function isExactEnvelope(value: unknown): value is BrowserStorageEnvelope {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const keys = Object.keys(value).sort();
|
||||
if (
|
||||
keys.length !== 3 ||
|
||||
keys[0] !== "expiresAt" ||
|
||||
keys[1] !== "schemaVersion" ||
|
||||
keys[2] !== "value"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return validEnvelopeMetadata(value as BrowserStorageEnvelope);
|
||||
}
|
||||
|
||||
function serializedByteLength(raw: string, limit: number): number {
|
||||
if (raw.length > limit) return limit + 1;
|
||||
return new TextEncoder().encode(raw).byteLength;
|
||||
}
|
||||
|
||||
function validateStorageValue(
|
||||
root: unknown,
|
||||
maxSerializedBytes: number,
|
||||
): BrowserStorageCodecResult<void> {
|
||||
let visited = 0;
|
||||
const ancestors = new Set<object>();
|
||||
|
||||
function visit(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
): BrowserStorageCodecResult<void> {
|
||||
visited += 1;
|
||||
if (visited > MAX_VALUE_NODES || depth > MAX_VALUE_DEPTH) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value))
|
||||
) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value.length > maxSerializedBytes) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (
|
||||
!Array.isArray(value) &&
|
||||
prototype !== Object.prototype &&
|
||||
prototype !== null
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
if (Reflect.ownKeys(value).some((key) => typeof key === "symbol")) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const childValues: unknown[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
if (
|
||||
!Number.isSafeInteger(value.length) ||
|
||||
value.length > MAX_VALUE_NODES
|
||||
) {
|
||||
return { ok: false, reason: "OVERSIZE" };
|
||||
}
|
||||
const descriptorKeys = Object.keys(descriptors).filter(
|
||||
(key) => key !== "length",
|
||||
);
|
||||
if (descriptorKeys.length !== value.length) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = descriptors[String(index)];
|
||||
if (
|
||||
!descriptor ||
|
||||
!descriptor.enumerable ||
|
||||
!("value" in descriptor)
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
childValues.push(descriptor.value);
|
||||
}
|
||||
} else {
|
||||
for (const [key, descriptor] of Object.entries(descriptors)) {
|
||||
if (
|
||||
key.length > maxSerializedBytes ||
|
||||
FORBIDDEN_RECORD_KEYS.has(key) ||
|
||||
!descriptor.enumerable ||
|
||||
!("value" in descriptor)
|
||||
) {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
childValues.push(descriptor.value);
|
||||
}
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
for (const child of childValues) {
|
||||
const result = visit(child, depth + 1);
|
||||
if (!result.ok) return result;
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
return visit(root, 0);
|
||||
} catch {
|
||||
return { ok: false, reason: "INVALID_VALUE" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export { createIndexedDbMaintenance } from "./indexeddb-maintenance.ts";
|
||||
export { createIndexedDbRuntime } from "./indexeddb-runtime.ts";
|
||||
export {
|
||||
assertValidIndexedDbDatasetGovernance,
|
||||
indexedDbPhysicalDatabaseName,
|
||||
} from "./indexeddb-governance.ts";
|
||||
|
||||
export type {
|
||||
IndexedDbCodec,
|
||||
IndexedDbCodecResult,
|
||||
IndexedDbCountBucket,
|
||||
IndexedDbDataMigrationPolicy,
|
||||
IndexedDbDataMigrationSource,
|
||||
IndexedDbDurabilityPolicy,
|
||||
IndexedDbIndexDefinition,
|
||||
IndexedDbKeyRangePlan,
|
||||
IndexedDbMaintenanceDependencies,
|
||||
IndexedDbObservation,
|
||||
IndexedDbQueryPlan,
|
||||
IndexedDbQueryPolicy,
|
||||
IndexedDbRuntimeDependencies,
|
||||
IndexedDbScheduler,
|
||||
IndexedDbSchemaMigration,
|
||||
IndexedDbSchemaOperation,
|
||||
} from "./indexeddb-types.ts";
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
BrowserDataOperation,
|
||||
BrowserDataResult,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import { browserDataFailure } from "../../browser-file-storage/result.ts";
|
||||
|
||||
function exceptionName(error: unknown): string {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"name" in error &&
|
||||
typeof error.name === "string"
|
||||
) {
|
||||
return error.name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the closed DOMException vocabulary without exposing an exception
|
||||
* object, message, key or stored value across the adapter boundary.
|
||||
*/
|
||||
export function mapIndexedDbException(
|
||||
error: unknown,
|
||||
operation: BrowserDataOperation,
|
||||
): BrowserDataResult<never> {
|
||||
switch (exceptionName(error)) {
|
||||
case "AbortError":
|
||||
return browserDataFailure("ABORTED", operation);
|
||||
case "ConstraintError":
|
||||
return browserDataFailure("CONFLICT", operation);
|
||||
case "DataCloneError":
|
||||
case "DataError":
|
||||
return browserDataFailure("CORRUPT_DATA", operation, {
|
||||
recovery: "READ_ONLY",
|
||||
});
|
||||
case "InvalidAccessError":
|
||||
case "InvalidStateError":
|
||||
case "NotFoundError":
|
||||
case "ReadOnlyError":
|
||||
case "TransactionInactiveError":
|
||||
case "VersionError":
|
||||
return browserDataFailure("MIGRATION_FAILED", operation, {
|
||||
recovery: "READ_ONLY",
|
||||
});
|
||||
case "NotAllowedError":
|
||||
case "SecurityError":
|
||||
return browserDataFailure("PERMISSION_DENIED", operation, {
|
||||
recovery: "ONLINE_ONLY",
|
||||
});
|
||||
case "NotReadableError":
|
||||
return browserDataFailure("NOT_READABLE", operation, {
|
||||
retryable: true,
|
||||
recovery: "REOPEN",
|
||||
});
|
||||
case "QuotaExceededError":
|
||||
case "NS_ERROR_DOM_QUOTA_REACHED":
|
||||
return browserDataFailure("QUOTA_EXCEEDED", operation, {
|
||||
recovery: "READ_ONLY",
|
||||
});
|
||||
case "UnknownError":
|
||||
return browserDataFailure("UNAVAILABLE", operation, {
|
||||
retryable: true,
|
||||
recovery: "REOPEN",
|
||||
});
|
||||
default:
|
||||
return browserDataFailure("UNAVAILABLE", operation, {
|
||||
retryable: true,
|
||||
recovery: "RETRY",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { IndexedDbDatasetScope } from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
|
||||
import {
|
||||
assertValidStoragePolicy,
|
||||
type BrowserStoragePolicy,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
export const INDEXEDDB_DATASET_BINDING_KEY = "dataset-binding";
|
||||
export const INDEXEDDB_DATASET_BUDGET_KEY = "dataset-budget";
|
||||
|
||||
const OPAQUE_SCOPE_TOKEN = /^[A-Za-z0-9_-]{16,48}$/u;
|
||||
|
||||
type StoredDatasetBinding = Readonly<{
|
||||
bindingKey: typeof INDEXEDDB_DATASET_BINDING_KEY;
|
||||
bindingVersion: 1;
|
||||
scope: IndexedDbDatasetScope;
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
}>;
|
||||
|
||||
export type IndexedDbBindingVerification =
|
||||
| Readonly<{ ok: true }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
reason: "ABORTED" | "CORRUPT" | "MISMATCH" | "MISSING" | "NATIVE_ERROR";
|
||||
error?: unknown;
|
||||
}>;
|
||||
|
||||
function validOpaqueToken(value: unknown): value is string {
|
||||
return typeof value === "string" && OPAQUE_SCOPE_TOKEN.test(value);
|
||||
}
|
||||
|
||||
export function assertValidIndexedDbDatasetGovernance(
|
||||
scope: IndexedDbDatasetScope,
|
||||
storagePolicy: BrowserStoragePolicy,
|
||||
): void {
|
||||
assertValidStoragePolicy(storagePolicy);
|
||||
if (
|
||||
!scope ||
|
||||
typeof scope !== "object" ||
|
||||
!validOpaqueToken(scope.authorityToken) ||
|
||||
!validOpaqueToken(scope.namespaceToken) ||
|
||||
!validOpaqueToken(scope.partitionToken) ||
|
||||
new Set([
|
||||
scope.authorityToken,
|
||||
scope.namespaceToken,
|
||||
scope.partitionToken,
|
||||
]).size !== 3 ||
|
||||
scope.accountScope !== storagePolicy.accountScope ||
|
||||
scope.authorityToken === storagePolicy.owner ||
|
||||
scope.namespaceToken === storagePolicy.namespace ||
|
||||
scope.partitionToken === storagePolicy.namespace ||
|
||||
(storagePolicy.classification === "PERSONAL" &&
|
||||
scope.accountScope !== "OPAQUE_PARTITION") ||
|
||||
(storagePolicy.classification === "CONFIDENTIAL" &&
|
||||
scope.accountScope !== "OPAQUE_PARTITION")
|
||||
) {
|
||||
throw new TypeError("IndexedDB dataset governance is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical identity is derived exclusively from opaque registry tokens. The
|
||||
* readable policy namespace and all business/account identifiers are excluded.
|
||||
*/
|
||||
export function indexedDbPhysicalDatabaseName(
|
||||
scope: IndexedDbDatasetScope,
|
||||
): string {
|
||||
if (
|
||||
!scope ||
|
||||
typeof scope !== "object" ||
|
||||
!validOpaqueToken(scope.authorityToken) ||
|
||||
!validOpaqueToken(scope.namespaceToken) ||
|
||||
!validOpaqueToken(scope.partitionToken)
|
||||
) {
|
||||
throw new TypeError("IndexedDB dataset scope is invalid.");
|
||||
}
|
||||
return `ca-idb-v1:${scope.authorityToken}.${scope.namespaceToken}.${scope.partitionToken}`;
|
||||
}
|
||||
|
||||
export function createIndexedDbDatasetBinding(
|
||||
scope: IndexedDbDatasetScope,
|
||||
storagePolicy: BrowserStoragePolicy,
|
||||
): StoredDatasetBinding {
|
||||
assertValidIndexedDbDatasetGovernance(scope, storagePolicy);
|
||||
return Object.freeze({
|
||||
bindingKey: INDEXEDDB_DATASET_BINDING_KEY,
|
||||
bindingVersion: 1,
|
||||
scope: Object.freeze({ ...scope }),
|
||||
storagePolicy: Object.freeze({
|
||||
...storagePolicy,
|
||||
retention: Object.freeze({ ...storagePolicy.retention }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function isStoredDatasetBinding(
|
||||
value: unknown,
|
||||
): value is StoredDatasetBinding {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const binding = value as Partial<StoredDatasetBinding>;
|
||||
if (
|
||||
binding.bindingKey !== INDEXEDDB_DATASET_BINDING_KEY ||
|
||||
binding.bindingVersion !== 1 ||
|
||||
!binding.scope ||
|
||||
!binding.storagePolicy
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
assertValidIndexedDbDatasetGovernance(
|
||||
binding.scope,
|
||||
binding.storagePolicy,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalPolicy(policy: BrowserStoragePolicy): string {
|
||||
return JSON.stringify([
|
||||
policy.owner,
|
||||
policy.namespace,
|
||||
policy.classification,
|
||||
policy.authority,
|
||||
policy.accountScope,
|
||||
policy.retention.kind,
|
||||
policy.retention.kind === "TTL"
|
||||
? policy.retention.maxAgeMs
|
||||
: null,
|
||||
policy.softBudgetBytes,
|
||||
policy.hardBudgetBytes,
|
||||
policy.evictionPriority,
|
||||
policy.logoutAction,
|
||||
policy.accountDeletionAction,
|
||||
policy.pressureAction,
|
||||
policy.unavailableFallback,
|
||||
]);
|
||||
}
|
||||
|
||||
export function sameIndexedDbDatasetBinding(
|
||||
value: unknown,
|
||||
expected: StoredDatasetBinding,
|
||||
): boolean {
|
||||
if (!isStoredDatasetBinding(value)) return false;
|
||||
return (
|
||||
value.scope.authorityToken === expected.scope.authorityToken &&
|
||||
value.scope.namespaceToken === expected.scope.namespaceToken &&
|
||||
value.scope.partitionToken === expected.scope.partitionToken &&
|
||||
value.scope.accountScope === expected.scope.accountScope &&
|
||||
canonicalPolicy(value.storagePolicy) ===
|
||||
canonicalPolicy(expected.storagePolicy)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues binding validation inside the versionchange transaction. Any mismatch
|
||||
* aborts that transaction, so schema changes cannot commit under the wrong
|
||||
* namespace or policy.
|
||||
*/
|
||||
export function queueIndexedDbUpgradeBinding(
|
||||
transaction: IDBTransaction,
|
||||
governanceStore: string,
|
||||
expected: StoredDatasetBinding,
|
||||
oldVersion: number,
|
||||
onRejected: () => void,
|
||||
): void {
|
||||
const store = transaction.objectStore(governanceStore);
|
||||
if (oldVersion === 0) {
|
||||
let addRequest: IDBRequest<IDBValidKey>;
|
||||
try {
|
||||
addRequest = store.add(expected);
|
||||
} catch {
|
||||
onRejected();
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
addRequest.onerror = () => onRejected();
|
||||
let budgetRequest: IDBRequest<IDBValidKey>;
|
||||
try {
|
||||
budgetRequest = store.add(
|
||||
Object.freeze({
|
||||
bindingKey: INDEXEDDB_DATASET_BUDGET_KEY,
|
||||
budgetVersion: 1,
|
||||
usedBytes: 0,
|
||||
receiptCount: 0,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
onRejected();
|
||||
transaction.abort();
|
||||
return;
|
||||
}
|
||||
budgetRequest.onerror = () => onRejected();
|
||||
return;
|
||||
}
|
||||
const request = store.get(INDEXEDDB_DATASET_BINDING_KEY);
|
||||
request.onerror = () => {
|
||||
onRejected();
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The native request/transaction error owns the terminal state.
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
if (!sameIndexedDbDatasetBinding(request.result, expected)) {
|
||||
onRejected();
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// The mismatch remains fail-closed even if abort already won.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-open verification protects non-upgrade opens and maintenance callers.
|
||||
*/
|
||||
export function verifyIndexedDbDatasetBinding(
|
||||
database: IDBDatabase,
|
||||
governanceStore: string,
|
||||
expected: StoredDatasetBinding,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IndexedDbBindingVerification> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.resolve({ ok: false, reason: "ABORTED" });
|
||||
}
|
||||
let transaction: IDBTransaction;
|
||||
try {
|
||||
transaction = database.transaction(governanceStore, "readonly");
|
||||
} catch (error) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
reason: "NATIVE_ERROR",
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let observed: unknown;
|
||||
let observedBudget: unknown;
|
||||
let requestError: unknown;
|
||||
let callerAborted = false;
|
||||
const finish = (result: IndexedDbBindingVerification) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
function onAbort(): void {
|
||||
callerAborted = true;
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// Completion determines the race.
|
||||
}
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
transaction.onerror = () => {
|
||||
requestError ??= transaction.error;
|
||||
};
|
||||
transaction.onabort = () =>
|
||||
finish(
|
||||
callerAborted
|
||||
? { ok: false, reason: "ABORTED" }
|
||||
: {
|
||||
ok: false,
|
||||
reason: "NATIVE_ERROR",
|
||||
error: requestError ?? transaction.error,
|
||||
},
|
||||
);
|
||||
transaction.oncomplete = () => {
|
||||
if (observed === undefined) {
|
||||
finish({ ok: false, reason: "MISSING" });
|
||||
} else if (!isStoredDatasetBinding(observed)) {
|
||||
finish({ ok: false, reason: "CORRUPT" });
|
||||
} else if (!sameIndexedDbDatasetBinding(observed, expected)) {
|
||||
finish({ ok: false, reason: "MISMATCH" });
|
||||
} else if (
|
||||
!observedBudget ||
|
||||
typeof observedBudget !== "object" ||
|
||||
(observedBudget as { bindingKey?: unknown }).bindingKey !==
|
||||
INDEXEDDB_DATASET_BUDGET_KEY ||
|
||||
(observedBudget as { budgetVersion?: unknown }).budgetVersion !==
|
||||
1 ||
|
||||
!Number.isSafeInteger(
|
||||
(observedBudget as { usedBytes?: unknown }).usedBytes,
|
||||
) ||
|
||||
typeof (observedBudget as { usedBytes?: unknown }).usedBytes !==
|
||||
"number" ||
|
||||
(observedBudget as { usedBytes: number }).usedBytes < 0 ||
|
||||
(observedBudget as { usedBytes: number }).usedBytes >
|
||||
expected.storagePolicy.hardBudgetBytes
|
||||
||
|
||||
!Number.isSafeInteger(
|
||||
(observedBudget as { receiptCount?: unknown }).receiptCount,
|
||||
) ||
|
||||
typeof (observedBudget as { receiptCount?: unknown })
|
||||
.receiptCount !== "number" ||
|
||||
(observedBudget as { receiptCount: number }).receiptCount < 0
|
||||
) {
|
||||
finish({ ok: false, reason: "CORRUPT" });
|
||||
} else {
|
||||
finish({ ok: true });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const request = transaction
|
||||
.objectStore(governanceStore)
|
||||
.get(INDEXEDDB_DATASET_BINDING_KEY);
|
||||
request.onerror = () => {
|
||||
requestError ??= request.error;
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
observed = request.result;
|
||||
};
|
||||
const budgetRequest = transaction
|
||||
.objectStore(governanceStore)
|
||||
.get(INDEXEDDB_DATASET_BUDGET_KEY);
|
||||
budgetRequest.onerror = () => {
|
||||
requestError ??= budgetRequest.error;
|
||||
};
|
||||
budgetRequest.onsuccess = () => {
|
||||
observedBudget = budgetRequest.result;
|
||||
};
|
||||
} catch (error) {
|
||||
requestError = error;
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
finish({ ok: false, reason: "NATIVE_ERROR", error });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
import type {
|
||||
IndexedDbIndexDefinition,
|
||||
IndexedDbSchemaMigration,
|
||||
IndexedDbSchemaOperation,
|
||||
} from "./indexeddb-types.ts";
|
||||
|
||||
const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/u;
|
||||
|
||||
function invalidMigration(): never {
|
||||
throw new DOMException("Invalid IndexedDB schema migration.", "InvalidStateError");
|
||||
}
|
||||
|
||||
function validIdentifier(value: string): boolean {
|
||||
return SAFE_IDENTIFIER.test(value);
|
||||
}
|
||||
|
||||
function validateIndex(index: IndexedDbIndexDefinition): void {
|
||||
if (
|
||||
!validIdentifier(index.name) ||
|
||||
(typeof index.keyPath !== "string" &&
|
||||
(!Array.isArray(index.keyPath) ||
|
||||
index.keyPath.length === 0 ||
|
||||
!index.keyPath.every(
|
||||
(entry) => typeof entry === "string" && entry.length > 0,
|
||||
))) ||
|
||||
(typeof index.keyPath === "string" && index.keyPath.length === 0)
|
||||
) {
|
||||
invalidMigration();
|
||||
}
|
||||
}
|
||||
|
||||
function validateOperation(operation: IndexedDbSchemaOperation): void {
|
||||
if (operation.kind === "CREATE_STORE") {
|
||||
if (
|
||||
!validIdentifier(operation.name) ||
|
||||
operation.keyPath.length === 0 ||
|
||||
operation.indexes?.some((index) => {
|
||||
try {
|
||||
validateIndex(index);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
) {
|
||||
invalidMigration();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
operation.kind !== "CREATE_INDEX" ||
|
||||
!validIdentifier(operation.store)
|
||||
) {
|
||||
invalidMigration();
|
||||
}
|
||||
validateIndex(operation.index);
|
||||
}
|
||||
|
||||
export function validateIndexedDbMigrations(
|
||||
schemaVersion: number,
|
||||
migrations: readonly IndexedDbSchemaMigration[],
|
||||
): void {
|
||||
if (
|
||||
!Number.isSafeInteger(schemaVersion) ||
|
||||
schemaVersion < 1 ||
|
||||
migrations.length !== schemaVersion
|
||||
) {
|
||||
invalidMigration();
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (let index = 0; index < migrations.length; index += 1) {
|
||||
const migration = migrations[index];
|
||||
if (
|
||||
!migration ||
|
||||
!validIdentifier(migration.id) ||
|
||||
ids.has(migration.id) ||
|
||||
migration.fromVersion !== index ||
|
||||
migration.toVersion !== index + 1
|
||||
) {
|
||||
invalidMigration();
|
||||
}
|
||||
ids.add(migration.id);
|
||||
migration.operations.forEach(validateOperation);
|
||||
}
|
||||
}
|
||||
|
||||
function createIndex(
|
||||
store: IDBObjectStore,
|
||||
index: IndexedDbIndexDefinition,
|
||||
): void {
|
||||
if (store.indexNames.contains(index.name)) invalidMigration();
|
||||
store.createIndex(
|
||||
index.name,
|
||||
Array.isArray(index.keyPath) ? [...index.keyPath] : index.keyPath,
|
||||
{
|
||||
unique: index.unique ?? false,
|
||||
multiEntry: index.multiEntry ?? false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function applyOperation(
|
||||
db: IDBDatabase,
|
||||
transaction: IDBTransaction,
|
||||
operation: IndexedDbSchemaOperation,
|
||||
): void {
|
||||
switch (operation.kind) {
|
||||
case "CREATE_STORE": {
|
||||
if (db.objectStoreNames.contains(operation.name)) invalidMigration();
|
||||
const store = db.createObjectStore(operation.name, {
|
||||
keyPath: operation.keyPath,
|
||||
autoIncrement: operation.autoIncrement ?? false,
|
||||
});
|
||||
for (const index of operation.indexes ?? []) createIndex(store, index);
|
||||
return;
|
||||
}
|
||||
case "CREATE_INDEX": {
|
||||
if (!db.objectStoreNames.contains(operation.store)) invalidMigration();
|
||||
createIndex(transaction.objectStore(operation.store), operation.index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyIndexedDbMigrations(
|
||||
db: IDBDatabase,
|
||||
transaction: IDBTransaction,
|
||||
oldVersion: number,
|
||||
newVersion: number,
|
||||
migrations: readonly IndexedDbSchemaMigration[],
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(oldVersion) ||
|
||||
!Number.isSafeInteger(newVersion) ||
|
||||
oldVersion < 0 ||
|
||||
newVersion <= oldVersion ||
|
||||
newVersion > migrations.length
|
||||
) {
|
||||
invalidMigration();
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (let version = oldVersion + 1; version <= newVersion; version += 1) {
|
||||
const migration = migrations[version - 1];
|
||||
if (
|
||||
!migration ||
|
||||
migration.fromVersion !== version - 1 ||
|
||||
migration.toVersion !== version
|
||||
) {
|
||||
invalidMigration();
|
||||
}
|
||||
for (const operation of migration.operations) {
|
||||
applyOperation(db, transaction, operation);
|
||||
}
|
||||
applied += 1;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
export function assertIndexedDbRuntimeStores(
|
||||
db: IDBDatabase,
|
||||
recordStore: string,
|
||||
governanceStore: string,
|
||||
retentionStore: string,
|
||||
retentionEligibilityIndex: string,
|
||||
lifecycleMetadataStores: readonly string[],
|
||||
idempotencyStore: string,
|
||||
idempotencyExpiryIndex: string,
|
||||
): void {
|
||||
if (
|
||||
new Set([
|
||||
recordStore,
|
||||
governanceStore,
|
||||
retentionStore,
|
||||
idempotencyStore,
|
||||
...lifecycleMetadataStores,
|
||||
]).size !== 4 + lifecycleMetadataStores.length ||
|
||||
!validIdentifier(recordStore) ||
|
||||
!validIdentifier(governanceStore) ||
|
||||
!validIdentifier(retentionStore) ||
|
||||
!validIdentifier(retentionEligibilityIndex) ||
|
||||
lifecycleMetadataStores.some(
|
||||
(store) =>
|
||||
!validIdentifier(store) ||
|
||||
!db.objectStoreNames.contains(store),
|
||||
) ||
|
||||
!validIdentifier(idempotencyStore) ||
|
||||
!validIdentifier(idempotencyExpiryIndex) ||
|
||||
!db.objectStoreNames.contains(recordStore) ||
|
||||
!db.objectStoreNames.contains(governanceStore) ||
|
||||
!db.objectStoreNames.contains(retentionStore) ||
|
||||
!db.objectStoreNames.contains(idempotencyStore)
|
||||
) {
|
||||
invalidMigration();
|
||||
}
|
||||
const transaction = db.transaction(
|
||||
[retentionStore, idempotencyStore],
|
||||
"readonly",
|
||||
);
|
||||
transaction
|
||||
.objectStore(retentionStore)
|
||||
.index(retentionEligibilityIndex);
|
||||
transaction
|
||||
.objectStore(idempotencyStore)
|
||||
.index(idempotencyExpiryIndex);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
import type {
|
||||
IndexedDbConnectionStatus,
|
||||
IndexedDbCursor,
|
||||
IndexedDbCursorKey,
|
||||
IndexedDbDatasetScope,
|
||||
IndexedDbLifecycleAuthorityDecision,
|
||||
IndexedDbLifecycleAuthorityRequest,
|
||||
} from "../../../application/ports/browser-file-storage/indexeddb-port.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataOperation,
|
||||
BrowserStoragePolicy,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
export type IndexedDbCodecResult<Value> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false }>;
|
||||
|
||||
/**
|
||||
* The codec is the only boundary allowed to turn an IndexedDB structured
|
||||
* clone into a trusted value. It must accept every retained historical record
|
||||
* version and emit only current-version wire values.
|
||||
*/
|
||||
export interface IndexedDbCodec<Value, WireValue> {
|
||||
readonly currentVersion: number;
|
||||
encode(value: Value): IndexedDbCodecResult<WireValue>;
|
||||
/**
|
||||
* Deterministic conservative byte estimate for the encoded wire value.
|
||||
* Returning an invalid value or throwing rejects the write fail-closed.
|
||||
*/
|
||||
measureStoredBytes(value: WireValue): number;
|
||||
decode(
|
||||
codecVersion: number,
|
||||
value: unknown,
|
||||
): IndexedDbCodecResult<Value>;
|
||||
/**
|
||||
* Returns lowercase SHA-256 hex over a canonical, domain-approved wire
|
||||
* representation. Raw labels, identifiers or reversible encodings are
|
||||
* rejected by the runtime and must never be persisted as fingerprints. The
|
||||
* canonicalization and digest contract must remain stable for at least the
|
||||
* receipt retention plus supported rollback window.
|
||||
*/
|
||||
fingerprint(value: WireValue): string | Promise<string>;
|
||||
}
|
||||
|
||||
export type IndexedDbIndexDefinition = Readonly<{
|
||||
name: string;
|
||||
keyPath: string | readonly string[];
|
||||
unique?: boolean;
|
||||
multiEntry?: boolean;
|
||||
}>;
|
||||
|
||||
export type IndexedDbSchemaOperation =
|
||||
| Readonly<{
|
||||
kind: "CREATE_STORE";
|
||||
name: string;
|
||||
keyPath: string;
|
||||
autoIncrement?: boolean;
|
||||
indexes?: readonly IndexedDbIndexDefinition[];
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "CREATE_INDEX";
|
||||
store: string;
|
||||
index: IndexedDbIndexDefinition;
|
||||
}>;
|
||||
|
||||
export type IndexedDbSchemaMigration = Readonly<{
|
||||
id: string;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
operations: readonly IndexedDbSchemaOperation[];
|
||||
}>;
|
||||
|
||||
export type IndexedDbKeyRangePlan =
|
||||
| Readonly<{ kind: "ONLY"; value: IndexedDbCursorKey }>
|
||||
| Readonly<{
|
||||
kind: "LOWER";
|
||||
lower: IndexedDbCursorKey;
|
||||
open?: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "UPPER";
|
||||
upper: IndexedDbCursorKey;
|
||||
open?: boolean;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "BOUND";
|
||||
lower: IndexedDbCursorKey;
|
||||
upper: IndexedDbCursorKey;
|
||||
lowerOpen?: boolean;
|
||||
upperOpen?: boolean;
|
||||
}>;
|
||||
|
||||
export type IndexedDbQueryPlan = Readonly<{
|
||||
index?: string;
|
||||
range?: IndexedDbKeyRangePlan;
|
||||
direction?: "next" | "prev";
|
||||
limit: number;
|
||||
}>;
|
||||
|
||||
export interface IndexedDbQueryPolicy<Query> {
|
||||
plan(query: Query, cursor: IndexedDbCursor | null): IndexedDbQueryPlan;
|
||||
}
|
||||
|
||||
export type IndexedDbDataMigrationSource = Readonly<{
|
||||
key: string;
|
||||
fromCodecVersion: number;
|
||||
payload: unknown;
|
||||
signal: AbortSignal | undefined;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Owns all domain-aware historical payload conversion. It runs outside an
|
||||
* IndexedDB transaction, so asynchronous validation/crypto cannot accidentally
|
||||
* make a transaction inactive.
|
||||
*/
|
||||
export interface IndexedDbDataMigrationPolicy<WireValue> {
|
||||
readonly migrationId: string;
|
||||
readonly targetCodecVersion: number;
|
||||
measureStoredBytes(value: WireValue): number;
|
||||
/**
|
||||
* Must be backed by product rollout/session authority that keeps N-1
|
||||
* old-codec writers drained for the entire migration and contract window.
|
||||
* BroadcastChannel or a best-effort tab hint is not a correctness fence.
|
||||
*/
|
||||
isOldWriterDrainConfirmed(
|
||||
signal: AbortSignal | undefined,
|
||||
): boolean | Promise<boolean>;
|
||||
migrate(
|
||||
source: IndexedDbDataMigrationSource,
|
||||
):
|
||||
| IndexedDbCodecResult<WireValue>
|
||||
| Promise<IndexedDbCodecResult<WireValue>>;
|
||||
}
|
||||
|
||||
export type IndexedDbCountBucket =
|
||||
| "0"
|
||||
| "1"
|
||||
| "2-10"
|
||||
| "11-100"
|
||||
| "101+";
|
||||
|
||||
/**
|
||||
* Safe observation event. It intentionally contains no database/store/index
|
||||
* name, key, account identifier, value or native exception.
|
||||
*/
|
||||
export type IndexedDbObservation = Readonly<{
|
||||
operation: BrowserDataOperation;
|
||||
outcome: "SUCCESS" | "FAILED" | "ABORTED" | "BLOCKED";
|
||||
schemaVersion: number;
|
||||
countBucket: IndexedDbCountBucket;
|
||||
failureCode?: BrowserDataFailureCode;
|
||||
}>;
|
||||
|
||||
export type IndexedDbScheduler = Readonly<{
|
||||
setTimeout(callback: () => void, milliseconds: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}>;
|
||||
|
||||
export type IndexedDbDurabilityPolicy = Readonly<{
|
||||
read?: "default" | "strict" | "relaxed";
|
||||
write?: "default" | "strict" | "relaxed";
|
||||
}>;
|
||||
|
||||
export type IndexedDbRuntimeDependencies<Value, WireValue, Query> = Readonly<{
|
||||
scope: IndexedDbDatasetScope;
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
/**
|
||||
* Optional deployment assertion only. It cannot override the derived name
|
||||
* and construction fails unless it is byte-for-byte equal.
|
||||
*/
|
||||
databaseNameAssertion?: string;
|
||||
schemaVersion: number;
|
||||
recordStore: string;
|
||||
governanceStore: string;
|
||||
retentionStore: string;
|
||||
retentionEligibilityIndex: string;
|
||||
/**
|
||||
* Adapter-owned stores (for example migration checkpoints) whose metadata
|
||||
* must be removed by partition/session lifecycle purge. Never include the
|
||||
* immutable governance store.
|
||||
*/
|
||||
lifecycleMetadataStores: readonly string[];
|
||||
idempotencyStore: string;
|
||||
idempotencyExpiryIndex: string;
|
||||
receiptRetentionMs: number;
|
||||
maxIdempotencyReceipts: number;
|
||||
migrations: readonly IndexedDbSchemaMigration[];
|
||||
codec: IndexedDbCodec<Value, WireValue>;
|
||||
queryPolicy: IndexedDbQueryPolicy<Query>;
|
||||
factory?: IDBFactory;
|
||||
keyRange?: Pick<
|
||||
typeof IDBKeyRange,
|
||||
"only" | "lowerBound" | "upperBound" | "bound"
|
||||
>;
|
||||
durability?: IndexedDbDurabilityPolicy;
|
||||
blockedTimeoutMs?: number;
|
||||
nowEpochMilliseconds?: () => number;
|
||||
scheduler?: IndexedDbScheduler;
|
||||
nowMonotonicMilliseconds?: () => number;
|
||||
authorizeLifecycle(
|
||||
request: IndexedDbLifecycleAuthorityRequest,
|
||||
):
|
||||
| IndexedDbLifecycleAuthorityDecision
|
||||
| Promise<IndexedDbLifecycleAuthorityDecision>;
|
||||
observe?: (event: IndexedDbObservation) => void;
|
||||
onVersionChange?: (
|
||||
status: Extract<IndexedDbConnectionStatus, { kind: "CLOSED" }>,
|
||||
) => void;
|
||||
}>;
|
||||
|
||||
export type IndexedDbMaintenanceDependencies<WireValue> = Readonly<{
|
||||
scope: IndexedDbDatasetScope;
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
databaseNameAssertion?: string;
|
||||
schemaVersion: number;
|
||||
recordStore: string;
|
||||
governanceStore: string;
|
||||
retentionStore: string;
|
||||
checkpointStore: string;
|
||||
checkpointKey: string;
|
||||
idempotencyStore: string;
|
||||
idempotencyExpiryIndex: string;
|
||||
migrationPolicy: IndexedDbDataMigrationPolicy<WireValue>;
|
||||
factory?: IDBFactory;
|
||||
keyRange?: Pick<
|
||||
typeof IDBKeyRange,
|
||||
"lowerBound" | "upperBound"
|
||||
>;
|
||||
durability?: IndexedDbDurabilityPolicy;
|
||||
now?: () => number;
|
||||
nowEpochMilliseconds?: () => number;
|
||||
observe?: (event: IndexedDbObservation) => void;
|
||||
}>;
|
||||
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
DurableObjectDescriptor,
|
||||
DurableObjectMaintenancePort,
|
||||
DurableObjectStorePort,
|
||||
OpenedDurableObject,
|
||||
OpfsCapabilities,
|
||||
OpfsPolicyMaintenanceReport,
|
||||
OpfsReconciliationReport,
|
||||
OpfsStorageScope,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import {
|
||||
type BrowserDataFailureCode,
|
||||
type BrowserDataResult,
|
||||
type BrowserStoragePolicy,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
createIndexedDbOpfsJournal,
|
||||
type IndexedDbOpfsJournal,
|
||||
} from "./indexeddb-opfs-journal.ts";
|
||||
import {
|
||||
createOpfsByteStoreAdapter,
|
||||
type OpfsMaintenanceAuthorityConsumer,
|
||||
type OpfsMaintenanceAuthorityProvider,
|
||||
} from "./opfs-byte-store-adapter.ts";
|
||||
import {
|
||||
resolveOpfsRuntimePolicy,
|
||||
snapshotOpfsStoragePolicy,
|
||||
snapshotOpfsStorageScope,
|
||||
type OpfsRuntimePolicy,
|
||||
type OpfsSafeObserver,
|
||||
} from "./opfs-policy.ts";
|
||||
import {
|
||||
createOwnedOpfsWorkerClient,
|
||||
type OwnedOpfsWorkerClient,
|
||||
} from "./opfs-worker-client.ts";
|
||||
|
||||
export type BrowserOpfsRuntime = Readonly<{
|
||||
objects: DurableObjectStorePort;
|
||||
maintenance: DurableObjectMaintenancePort;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
export type BrowserOpfsRuntimeDependencies = Readonly<{
|
||||
workerUrl: string | URL;
|
||||
workerName?: string;
|
||||
/**
|
||||
* Optional only for assertion/testing. When provided it must equal the
|
||||
* deterministic name derived from scope.authorityToken.
|
||||
*/
|
||||
databaseName?: string;
|
||||
scope: OpfsStorageScope;
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
policy: OpfsRuntimePolicy;
|
||||
indexedDbFactory?: IDBFactory;
|
||||
createTransactionId?: () => string;
|
||||
createWorkerRequestId?: () => string;
|
||||
createFencingToken?: () => string;
|
||||
now?: () => number;
|
||||
blockedTimeoutMs?: number;
|
||||
observer?: OpfsSafeObserver;
|
||||
requestMaintenanceAuthority?: OpfsMaintenanceAuthorityProvider;
|
||||
consumeMaintenanceAuthority?: OpfsMaintenanceAuthorityConsumer;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional owned composition. Importing this module has no side effects and
|
||||
* does not add OPFS to the default bootstrap or bundle. The caller must point
|
||||
* workerUrl at an entry that starts startBrowserOpfsDedicatedWorker with the
|
||||
* same resolved policy.
|
||||
*/
|
||||
export function createBrowserOpfsRuntime(
|
||||
inputDependencies: BrowserOpfsRuntimeDependencies,
|
||||
): BrowserOpfsRuntime {
|
||||
const policy = resolveOpfsRuntimePolicy(inputDependencies.policy);
|
||||
const scope = snapshotOpfsStorageScope(inputDependencies.scope);
|
||||
const storagePolicy = snapshotOpfsStoragePolicy(
|
||||
inputDependencies.storagePolicy,
|
||||
);
|
||||
if (
|
||||
storagePolicy.namespace !== scope.namespace
|
||||
) {
|
||||
throw new TypeError("Browser OPFS scope binding is invalid.");
|
||||
}
|
||||
const dependencies: BrowserOpfsRuntimeDependencies = Object.freeze({
|
||||
...inputDependencies,
|
||||
scope,
|
||||
storagePolicy,
|
||||
policy,
|
||||
});
|
||||
const support = inspectBrowserOpfsSupport(policy);
|
||||
if (!support.ok) {
|
||||
return failedBrowserOpfsRuntime("UNSUPPORTED");
|
||||
}
|
||||
const journal: IndexedDbOpfsJournal = createIndexedDbOpfsJournal({
|
||||
authorityToken: dependencies.scope.authorityToken,
|
||||
databaseName: dependencies.databaseName,
|
||||
factory: dependencies.indexedDbFactory,
|
||||
createFencingToken: dependencies.createFencingToken,
|
||||
blockedTimeoutMs: dependencies.blockedTimeoutMs,
|
||||
});
|
||||
|
||||
let workerClient: OwnedOpfsWorkerClient;
|
||||
try {
|
||||
workerClient = createOwnedOpfsWorkerClient({
|
||||
workerUrl: dependencies.workerUrl,
|
||||
workerName: dependencies.workerName,
|
||||
policy,
|
||||
createRequestId: dependencies.createWorkerRequestId,
|
||||
});
|
||||
} catch {
|
||||
journal.close();
|
||||
return failedBrowserOpfsRuntime("UNAVAILABLE");
|
||||
}
|
||||
|
||||
const byteStore = createOpfsByteStoreAdapter({
|
||||
journal,
|
||||
worker: workerClient.gateway,
|
||||
scope: dependencies.scope,
|
||||
storagePolicy: dependencies.storagePolicy,
|
||||
policy,
|
||||
createTransactionId: dependencies.createTransactionId,
|
||||
now: dependencies.now,
|
||||
observer: dependencies.observer,
|
||||
requestMaintenanceAuthority:
|
||||
dependencies.requestMaintenanceAuthority,
|
||||
consumeMaintenanceAuthority:
|
||||
dependencies.consumeMaintenanceAuthority,
|
||||
});
|
||||
let closed = false;
|
||||
|
||||
return Object.freeze({
|
||||
...byteStore,
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
workerClient.terminate();
|
||||
journal.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-effect-free platform probe. It is also the single preflight used by
|
||||
* createBrowserOpfsRuntime, so unsupported engines return the same closed
|
||||
* Result contract instead of throwing during Worker construction.
|
||||
*/
|
||||
export function inspectBrowserOpfsSupport(
|
||||
policy: OpfsRuntimePolicy = resolveOpfsRuntimePolicy(),
|
||||
): BrowserDataResult<OpfsCapabilities> {
|
||||
const dedicatedWorkerAvailable = typeof Worker !== "undefined";
|
||||
const opfsAvailable =
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.storage?.getDirectory === "function";
|
||||
const webLocksAvailable =
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.locks?.request === "function";
|
||||
const synchronousAccessHandleAvailable =
|
||||
typeof FileSystemFileHandle !== "undefined" &&
|
||||
"createSyncAccessHandle" in FileSystemFileHandle.prototype;
|
||||
const capabilities: OpfsCapabilities = Object.freeze({
|
||||
available:
|
||||
dedicatedWorkerAvailable &&
|
||||
opfsAvailable &&
|
||||
webLocksAvailable &&
|
||||
(synchronousAccessHandleAvailable ||
|
||||
policy.allowAsyncWritableChunkFallback),
|
||||
dedicatedWorkerRequired: true,
|
||||
crossContextMutationLockAvailable: webLocksAvailable,
|
||||
synchronousAccessHandleAvailable,
|
||||
});
|
||||
return capabilities.available
|
||||
? browserDataSuccess(capabilities)
|
||||
: browserDataFailure("UNSUPPORTED", "OBJECT_READ", {
|
||||
recovery: "ONLINE_ONLY",
|
||||
});
|
||||
}
|
||||
|
||||
function failedBrowserOpfsRuntime(
|
||||
code: Extract<
|
||||
BrowserDataFailureCode,
|
||||
"UNAVAILABLE" | "UNSUPPORTED"
|
||||
>,
|
||||
): BrowserOpfsRuntime {
|
||||
const failure = <Value>(
|
||||
operation:
|
||||
| "OBJECT_READ"
|
||||
| "OBJECT_WRITE"
|
||||
| "OBJECT_DELETE"
|
||||
| "OBJECT_RECONCILE",
|
||||
): BrowserDataResult<Value> =>
|
||||
browserDataFailure(code, operation, {
|
||||
retryable: code === "UNAVAILABLE",
|
||||
recovery: code === "UNAVAILABLE" ? "RETRY" : "ONLINE_ONLY",
|
||||
});
|
||||
return Object.freeze({
|
||||
objects: Object.freeze({
|
||||
capabilities: async () =>
|
||||
failure<OpfsCapabilities>("OBJECT_READ"),
|
||||
put: async () =>
|
||||
failure<DurableObjectDescriptor>("OBJECT_WRITE"),
|
||||
open: async () =>
|
||||
failure<OpenedDurableObject>("OBJECT_READ"),
|
||||
remove: async () => failure<void>("OBJECT_DELETE"),
|
||||
}),
|
||||
maintenance: Object.freeze({
|
||||
reconcile: async () =>
|
||||
failure<OpfsReconciliationReport>("OBJECT_RECONCILE"),
|
||||
enforcePolicies: async () =>
|
||||
failure<OpfsPolicyMaintenanceReport>("OBJECT_RECONCILE"),
|
||||
}),
|
||||
close() {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export {
|
||||
createBrowserOpfsRuntime,
|
||||
inspectBrowserOpfsSupport,
|
||||
type BrowserOpfsRuntime,
|
||||
type BrowserOpfsRuntimeDependencies,
|
||||
} from "./browser-opfs-runtime.ts";
|
||||
export {
|
||||
createOpfsByteStoreAdapter,
|
||||
type OpfsByteStore,
|
||||
type OpfsByteStoreDependencies,
|
||||
type OpfsMaintenanceAuthorityConsumer,
|
||||
type OpfsMaintenanceAuthorityDecision,
|
||||
type OpfsMaintenanceAuthorityProvider,
|
||||
type OpfsMaintenanceAuthorityRequest,
|
||||
} from "./opfs-byte-store-adapter.ts";
|
||||
export {
|
||||
createIndexedDbOpfsJournal,
|
||||
opfsJournalDatabaseName,
|
||||
type IndexedDbOpfsJournal,
|
||||
type IndexedDbOpfsJournalDependencies,
|
||||
} from "./indexeddb-opfs-journal.ts";
|
||||
export {
|
||||
DEFAULT_OPFS_RUNTIME_POLICY,
|
||||
resolveOpfsRuntimePolicy,
|
||||
type OpfsRuntimePolicy,
|
||||
type OpfsSafeObservation,
|
||||
type OpfsSafeObserver,
|
||||
} from "./opfs-policy.ts";
|
||||
export {
|
||||
createOpfsWorkerGateway,
|
||||
createOwnedOpfsWorkerClient,
|
||||
type OpfsWorkerClientDependencies,
|
||||
type OpfsWorkerLike,
|
||||
type OwnedOpfsWorkerClient,
|
||||
} from "./opfs-worker-client.ts";
|
||||
export {
|
||||
createBrowserOpfsWorkerRuntime,
|
||||
createOpfsWorkerRuntime,
|
||||
createWebLockLeaseManager,
|
||||
installOpfsWorkerMessageHandler,
|
||||
startBrowserOpfsDedicatedWorker,
|
||||
type BrowserOpfsWorkerDependencies,
|
||||
type OpfsMutationLease,
|
||||
type OpfsMutationLeaseManager,
|
||||
type OpfsWorkerMessageHost,
|
||||
type OpfsWorkerRuntime,
|
||||
} from "./opfs-worker-runtime.ts";
|
||||
export type {
|
||||
OpfsWorkerGateway,
|
||||
OpfsWorkerRequest,
|
||||
OpfsWorkerRequestBody,
|
||||
OpfsWorkerResponse,
|
||||
} from "./opfs-worker-protocol.ts";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
assertValidStoragePolicy,
|
||||
isValidByteLength,
|
||||
type BrowserDataFailureCode,
|
||||
type BrowserDataOperation,
|
||||
type BrowserStoragePolicy,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import type { OpfsStorageScope } from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
|
||||
export type OpfsRuntimePolicy = Readonly<{
|
||||
rootDirectoryName: string;
|
||||
mutationLockName: string;
|
||||
chunkSizeBytes: number;
|
||||
maxObjectBytes: number;
|
||||
maxChunkCount: number;
|
||||
rpcTimeoutMs: number;
|
||||
reconciliationBudgetMs: number;
|
||||
reconciliationBatchSize: number;
|
||||
orphanGracePeriodMs: number;
|
||||
orphanGcBatchSize: number;
|
||||
maxCancellationTombstones: number;
|
||||
allowAsyncWritableChunkFallback: boolean;
|
||||
isObjectIdAllowed: (objectId: string) => boolean;
|
||||
isMediaTypeAllowed: (mediaType: string) => boolean;
|
||||
}>;
|
||||
|
||||
export type OpfsSafeObservation = Readonly<{
|
||||
operation: BrowserDataOperation;
|
||||
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
|
||||
failureCode?: BrowserDataFailureCode;
|
||||
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB";
|
||||
transactionBucket?: "0" | "1_10" | "11_100" | "GT_100";
|
||||
}>;
|
||||
|
||||
export type OpfsSafeObserver = (observation: OpfsSafeObservation) => void;
|
||||
|
||||
const SAFE_SEGMENT = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
||||
const OPAQUE_OBJECT_ID = /^[A-Za-z0-9_-]{8,128}$/u;
|
||||
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:\s*;.*)?$/iu;
|
||||
|
||||
export const DEFAULT_OPFS_RUNTIME_POLICY: OpfsRuntimePolicy = Object.freeze({
|
||||
rootDirectoryName: "ca-frontend-opfs-v1",
|
||||
mutationLockName: "ca-frontend-opfs-v1:mutation",
|
||||
chunkSizeBytes: 4 * 1024 * 1024,
|
||||
maxObjectBytes: 2 * 1024 * 1024 * 1024,
|
||||
maxChunkCount: 512,
|
||||
rpcTimeoutMs: 60_000,
|
||||
reconciliationBudgetMs: 5_000,
|
||||
reconciliationBatchSize: 100,
|
||||
orphanGracePeriodMs: 24 * 60 * 60 * 1_000,
|
||||
orphanGcBatchSize: 100,
|
||||
maxCancellationTombstones: 1_024,
|
||||
allowAsyncWritableChunkFallback: true,
|
||||
isObjectIdAllowed: (objectId) => OPAQUE_OBJECT_ID.test(objectId),
|
||||
isMediaTypeAllowed: (mediaType) => MEDIA_TYPE.test(mediaType),
|
||||
});
|
||||
|
||||
export function resolveOpfsRuntimePolicy(
|
||||
policy: Partial<OpfsRuntimePolicy> = {},
|
||||
): OpfsRuntimePolicy {
|
||||
const resolved: OpfsRuntimePolicy = Object.freeze({
|
||||
...DEFAULT_OPFS_RUNTIME_POLICY,
|
||||
...policy,
|
||||
});
|
||||
assertOpfsRuntimePolicy(resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function assertOpfsRuntimePolicy(policy: OpfsRuntimePolicy): void {
|
||||
if (
|
||||
!SAFE_SEGMENT.test(policy.rootDirectoryName) ||
|
||||
policy.mutationLockName.length === 0 ||
|
||||
!Number.isSafeInteger(policy.chunkSizeBytes) ||
|
||||
policy.chunkSizeBytes < 64 * 1024 ||
|
||||
policy.chunkSizeBytes > 64 * 1024 * 1024 ||
|
||||
!isValidByteLength(policy.maxObjectBytes) ||
|
||||
policy.maxObjectBytes < policy.chunkSizeBytes ||
|
||||
!Number.isSafeInteger(policy.maxChunkCount) ||
|
||||
policy.maxChunkCount < 1 ||
|
||||
policy.maxObjectBytes > policy.chunkSizeBytes * policy.maxChunkCount ||
|
||||
!Number.isSafeInteger(policy.rpcTimeoutMs) ||
|
||||
policy.rpcTimeoutMs < 1_000 ||
|
||||
!Number.isSafeInteger(policy.reconciliationBudgetMs) ||
|
||||
policy.reconciliationBudgetMs < 1 ||
|
||||
policy.reconciliationBudgetMs > 60_000 ||
|
||||
!Number.isSafeInteger(policy.reconciliationBatchSize) ||
|
||||
policy.reconciliationBatchSize < 1 ||
|
||||
policy.reconciliationBatchSize > 1_000 ||
|
||||
!Number.isSafeInteger(policy.orphanGracePeriodMs) ||
|
||||
policy.orphanGracePeriodMs < 60_000 ||
|
||||
!Number.isSafeInteger(policy.orphanGcBatchSize) ||
|
||||
policy.orphanGcBatchSize < 1 ||
|
||||
policy.orphanGcBatchSize > 1_000 ||
|
||||
!Number.isSafeInteger(policy.maxCancellationTombstones) ||
|
||||
policy.maxCancellationTombstones < 16 ||
|
||||
policy.maxCancellationTombstones > 10_000 ||
|
||||
typeof policy.isObjectIdAllowed !== "function" ||
|
||||
typeof policy.isMediaTypeAllowed !== "function"
|
||||
) {
|
||||
throw new TypeError("OPFS runtime policy is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateObjectWriteInput(
|
||||
input: Readonly<{
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
expectedGeneration: number | null;
|
||||
mediaType: string;
|
||||
byteLength: number | null;
|
||||
storagePolicy: Parameters<typeof assertValidStoragePolicy>[0];
|
||||
}>,
|
||||
policy: OpfsRuntimePolicy,
|
||||
): boolean {
|
||||
try {
|
||||
assertValidStoragePolicy(input.storagePolicy);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isValidOpfsStorageScope(input.scope) &&
|
||||
input.scope.namespace === input.storagePolicy.namespace &&
|
||||
policy.isObjectIdAllowed(input.objectId) &&
|
||||
policy.isMediaTypeAllowed(input.mediaType) &&
|
||||
(input.expectedGeneration === null ||
|
||||
(Number.isSafeInteger(input.expectedGeneration) &&
|
||||
input.expectedGeneration > 0)) &&
|
||||
input.byteLength !== null &&
|
||||
isValidByteLength(input.byteLength) &&
|
||||
input.byteLength <= policy.maxObjectBytes &&
|
||||
Math.ceil(input.byteLength / policy.chunkSizeBytes) <=
|
||||
policy.maxChunkCount
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidOpfsStorageScope(
|
||||
scope: OpfsStorageScope,
|
||||
): boolean {
|
||||
return (
|
||||
scope.namespace.length > 0 &&
|
||||
scope.namespace.length <= 64 &&
|
||||
OPAQUE_OBJECT_ID.test(scope.authorityToken) &&
|
||||
OPAQUE_OBJECT_ID.test(scope.namespaceToken) &&
|
||||
OPAQUE_OBJECT_ID.test(scope.partitionToken)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the registry binding at composition time. Callers may own mutable
|
||||
* config objects, so no OPFS operation is allowed to retain those references.
|
||||
*/
|
||||
export function snapshotOpfsStorageScope(
|
||||
input: OpfsStorageScope,
|
||||
): OpfsStorageScope {
|
||||
try {
|
||||
const snapshot: OpfsStorageScope = Object.freeze({
|
||||
namespace: input.namespace,
|
||||
authorityToken: input.authorityToken,
|
||||
namespaceToken: input.namespaceToken,
|
||||
partitionToken: input.partitionToken,
|
||||
});
|
||||
if (!isValidOpfsStorageScope(snapshot)) throw new TypeError();
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new TypeError("OPFS storage scope is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep enough for the closed BrowserStoragePolicy contract: retention is the
|
||||
* only nested value. Fields are copied explicitly so later caller mutation or
|
||||
* extension properties cannot alter the bound policy fingerprint.
|
||||
*/
|
||||
export function snapshotOpfsStoragePolicy(
|
||||
input: BrowserStoragePolicy,
|
||||
): BrowserStoragePolicy {
|
||||
try {
|
||||
const retention: BrowserStoragePolicy["retention"] =
|
||||
input.retention.kind === "TTL"
|
||||
? Object.freeze({
|
||||
kind: "TTL",
|
||||
maxAgeMs: input.retention.maxAgeMs,
|
||||
})
|
||||
: Object.freeze({ kind: input.retention.kind });
|
||||
const snapshot: BrowserStoragePolicy = Object.freeze({
|
||||
owner: input.owner,
|
||||
namespace: input.namespace,
|
||||
classification: input.classification,
|
||||
authority: input.authority,
|
||||
accountScope: input.accountScope,
|
||||
retention,
|
||||
softBudgetBytes: input.softBudgetBytes,
|
||||
hardBudgetBytes: input.hardBudgetBytes,
|
||||
evictionPriority: input.evictionPriority,
|
||||
logoutAction: input.logoutAction,
|
||||
accountDeletionAction: input.accountDeletionAction,
|
||||
pressureAction: input.pressureAction,
|
||||
unavailableFallback: input.unavailableFallback,
|
||||
});
|
||||
assertValidStoragePolicy(snapshot);
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new TypeError("OPFS storage policy is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
export function byteBucket(
|
||||
byteLength: number,
|
||||
): NonNullable<OpfsSafeObservation["byteBucket"]> {
|
||||
if (byteLength === 0) return "0";
|
||||
if (byteLength <= 1024 * 1024) return "1B_1MiB";
|
||||
if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB";
|
||||
if (byteLength <= 256 * 1024 * 1024) return "16MiB_256MiB";
|
||||
return "GT_256MiB";
|
||||
}
|
||||
|
||||
export function transactionBucket(
|
||||
count: number,
|
||||
): NonNullable<OpfsSafeObservation["transactionBucket"]> {
|
||||
if (count === 0) return "0";
|
||||
if (count <= 10) return "1_10";
|
||||
if (count <= 100) return "11_100";
|
||||
return "GT_100";
|
||||
}
|
||||
|
||||
export function observeOpfsSafely(
|
||||
observer: OpfsSafeObserver | undefined,
|
||||
observation: OpfsSafeObservation,
|
||||
): void {
|
||||
try {
|
||||
observer?.(Object.freeze({ ...observation }));
|
||||
} catch {
|
||||
// Persistence behavior never depends on observability.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import type {
|
||||
OpfsCapabilities,
|
||||
OpfsPreparedObject,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataOperation,
|
||||
BrowserDataResult,
|
||||
ByteSource,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import type { OpfsRuntimePolicy } from "./opfs-policy.ts";
|
||||
import type {
|
||||
OpfsWorkerGateway,
|
||||
OpfsOrphanCandidateBatch,
|
||||
OpfsOrphanDeleteResult,
|
||||
OpfsWorkerRequest,
|
||||
OpfsWorkerRequestBody,
|
||||
OpfsWorkerResponse,
|
||||
PreparePhysicalObjectRequest,
|
||||
} from "./opfs-worker-protocol.ts";
|
||||
|
||||
export interface OpfsWorkerLike {
|
||||
postMessage(message: OpfsWorkerRequest, transfer?: readonly Transferable[]): void;
|
||||
addEventListener(
|
||||
type: "message",
|
||||
listener: (event: MessageEvent<unknown>) => void,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: "message",
|
||||
listener: (event: MessageEvent<unknown>) => void,
|
||||
): void;
|
||||
addFailureEventListener?(listener: (event: Event) => void): void;
|
||||
removeFailureEventListener?(listener: (event: Event) => void): void;
|
||||
}
|
||||
|
||||
export type OpfsWorkerClientDependencies = Readonly<{
|
||||
worker: OpfsWorkerLike;
|
||||
policy: OpfsRuntimePolicy;
|
||||
createRequestId?: () => string;
|
||||
}>;
|
||||
|
||||
export type OwnedOpfsWorkerClient = Readonly<{
|
||||
gateway: OpfsWorkerGateway;
|
||||
terminate(): void;
|
||||
}>;
|
||||
|
||||
type PendingRequest = Readonly<{
|
||||
resolve: (response: OpfsWorkerResponse) => void;
|
||||
reject: (error: OpfsRpcError) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
removeAbortListener: () => void;
|
||||
}>;
|
||||
|
||||
class OpfsRpcError extends Error {
|
||||
readonly code: BrowserDataFailureCode;
|
||||
|
||||
constructor(code: BrowserDataFailureCode) {
|
||||
super("OPFS worker request failed.");
|
||||
this.name = "OpfsRpcError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpfsWorkerGateway(
|
||||
dependencies: OpfsWorkerClientDependencies,
|
||||
): OpfsWorkerGateway {
|
||||
const createRequestId =
|
||||
dependencies.createRequestId ??
|
||||
(() => globalThis.crypto.randomUUID());
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
let disposed = false;
|
||||
|
||||
const rejectAllPending = (): void => {
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.removeAbortListener();
|
||||
request.reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent<unknown>): void => {
|
||||
if (disposed) return;
|
||||
if (!isWorkerResponse(event.data)) return;
|
||||
const request = pending.get(event.data.requestId);
|
||||
if (!request) return;
|
||||
pending.delete(event.data.requestId);
|
||||
clearTimeout(request.timeout);
|
||||
request.removeAbortListener();
|
||||
request.resolve(event.data);
|
||||
};
|
||||
const onWorkerFailure = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
dependencies.worker.removeEventListener("message", onMessage);
|
||||
dependencies.worker.removeFailureEventListener?.(onWorkerFailure);
|
||||
rejectAllPending();
|
||||
};
|
||||
dependencies.worker.addEventListener("message", onMessage);
|
||||
dependencies.worker.addFailureEventListener?.(onWorkerFailure);
|
||||
|
||||
async function rpc(
|
||||
request: OpfsWorkerRequestBody,
|
||||
signal?: AbortSignal,
|
||||
transfer: readonly Transferable[] = [],
|
||||
): Promise<OpfsWorkerResponse> {
|
||||
if (disposed) throw new OpfsRpcError("UNAVAILABLE");
|
||||
if (signal?.aborted) throw new OpfsRpcError("ABORTED");
|
||||
const requestId = createRequestId();
|
||||
if (
|
||||
typeof requestId !== "string" ||
|
||||
requestId.length === 0 ||
|
||||
requestId.length > 128 ||
|
||||
pending.has(requestId)
|
||||
) {
|
||||
throw new OpfsRpcError("UNAVAILABLE");
|
||||
}
|
||||
const message = { ...request, requestId } as OpfsWorkerRequest;
|
||||
|
||||
return await new Promise<OpfsWorkerResponse>((resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
const item = pending.get(requestId);
|
||||
if (!item) return;
|
||||
pending.delete(requestId);
|
||||
clearTimeout(item.timeout);
|
||||
item.removeAbortListener();
|
||||
reject(new OpfsRpcError("ABORTED"));
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
const timeout = setTimeout(() => {
|
||||
const item = pending.get(requestId);
|
||||
if (!item) return;
|
||||
pending.delete(requestId);
|
||||
item.removeAbortListener();
|
||||
reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
}, dependencies.policy.rpcTimeoutMs);
|
||||
pending.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
removeAbortListener: () =>
|
||||
signal?.removeEventListener("abort", abort),
|
||||
});
|
||||
|
||||
try {
|
||||
dependencies.worker.postMessage(message, transfer);
|
||||
} catch {
|
||||
const item = pending.get(requestId);
|
||||
if (item) {
|
||||
pending.delete(requestId);
|
||||
clearTimeout(item.timeout);
|
||||
item.removeAbortListener();
|
||||
}
|
||||
reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function invoke<Value>(
|
||||
operation: BrowserDataOperation,
|
||||
request: OpfsWorkerRequestBody,
|
||||
signal?: AbortSignal,
|
||||
transfer: readonly Transferable[] = [],
|
||||
parse?: (value: unknown) => Value | null,
|
||||
): Promise<BrowserDataResult<Value>> {
|
||||
try {
|
||||
const response = await rpc(request, signal, transfer);
|
||||
if (!response.ok) {
|
||||
return failureResult(response.failure.code, operation);
|
||||
}
|
||||
const parsed = parse?.(response.value);
|
||||
if (parse && parsed === null) {
|
||||
return browserDataFailure("CORRUPT_DATA", operation, {
|
||||
recovery: "REHYDRATE",
|
||||
});
|
||||
}
|
||||
return browserDataSuccess(parsed as Value);
|
||||
} catch (error) {
|
||||
return failureResult(
|
||||
error instanceof OpfsRpcError ? error.code : "UNAVAILABLE",
|
||||
operation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function abortAndCleanup(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await rpc({ kind: "ABORT_PUT", scope, transactionId });
|
||||
} catch {
|
||||
// Journal reconciliation repeats cleanup after a crash or timeout.
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async capabilities() {
|
||||
return await invoke(
|
||||
"OBJECT_READ",
|
||||
{ kind: "CAPABILITIES" },
|
||||
undefined,
|
||||
[],
|
||||
parseCapabilities,
|
||||
);
|
||||
},
|
||||
|
||||
async preparePut(request: PreparePhysicalObjectRequest) {
|
||||
const begin = await invoke<void>(
|
||||
"OBJECT_WRITE",
|
||||
{
|
||||
kind: "BEGIN_PUT",
|
||||
transactionId: request.transactionId,
|
||||
scope: request.descriptor.scope,
|
||||
objectId: request.descriptor.objectId,
|
||||
generation: request.descriptor.generation,
|
||||
declaredByteLength: request.descriptor.byteLength,
|
||||
mediaType: request.descriptor.mediaType,
|
||||
createdAtEpochMs: request.descriptor.createdAtEpochMs,
|
||||
storagePolicy: request.descriptor.storagePolicy,
|
||||
chunkSizeBytes: dependencies.policy.chunkSizeBytes,
|
||||
},
|
||||
request.signal,
|
||||
);
|
||||
if (!begin.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return begin;
|
||||
}
|
||||
|
||||
let sequence = 0;
|
||||
let transferredBytes = 0;
|
||||
notifyProgress(request, "TRANSFERRING", 0);
|
||||
try {
|
||||
for await (const chunk of rechunk(
|
||||
request.source.stream(requiredSignal(request.signal)),
|
||||
dependencies.policy.chunkSizeBytes,
|
||||
dependencies.policy.maxObjectBytes,
|
||||
request.signal,
|
||||
)) {
|
||||
const chunkByteLength = chunk.byteLength;
|
||||
const append = await invoke<void>(
|
||||
"OBJECT_WRITE",
|
||||
{
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: request.descriptor.scope,
|
||||
transactionId: request.transactionId,
|
||||
sequence,
|
||||
bytes: chunk,
|
||||
},
|
||||
request.signal,
|
||||
[chunk],
|
||||
);
|
||||
if (!append.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return append;
|
||||
}
|
||||
sequence += 1;
|
||||
transferredBytes += chunkByteLength;
|
||||
notifyProgress(request, "TRANSFERRING", transferredBytes);
|
||||
}
|
||||
if (transferredBytes !== request.descriptor.byteLength) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return browserDataFailure("INTEGRITY_FAILED", "OBJECT_WRITE", {
|
||||
recovery: "RESELECT",
|
||||
});
|
||||
}
|
||||
notifyProgress(request, "VERIFYING", transferredBytes);
|
||||
const finished = await invoke(
|
||||
"OBJECT_WRITE",
|
||||
{
|
||||
kind: "FINISH_PUT",
|
||||
scope: request.descriptor.scope,
|
||||
transactionId: request.transactionId,
|
||||
},
|
||||
request.signal,
|
||||
[],
|
||||
parsePreparedObject,
|
||||
);
|
||||
if (!finished.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
}
|
||||
return finished;
|
||||
} catch (error) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return failureResult(
|
||||
error instanceof OpfsRpcError ? error.code : "NOT_READABLE",
|
||||
"OBJECT_WRITE",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async verifyObject(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke(
|
||||
"OBJECT_READ",
|
||||
{ kind: "VERIFY_OBJECT", preparedObject },
|
||||
signal,
|
||||
[],
|
||||
(value) => (typeof value === "boolean" ? value : null),
|
||||
);
|
||||
},
|
||||
|
||||
async openObject(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const verified = await invoke(
|
||||
"OBJECT_READ",
|
||||
{ kind: "VERIFY_OBJECT", preparedObject },
|
||||
signal,
|
||||
[],
|
||||
(value) => (typeof value === "boolean" ? value : null),
|
||||
);
|
||||
if (!verified.ok) return verified;
|
||||
if (!verified.value) {
|
||||
return browserDataFailure("INTEGRITY_FAILED", "OBJECT_READ", {
|
||||
recovery: "REHYDRATE",
|
||||
});
|
||||
}
|
||||
|
||||
const source: ByteSource = Object.freeze({
|
||||
byteLength: preparedObject.descriptor.byteLength,
|
||||
async *stream(streamSignal: AbortSignal) {
|
||||
for (const chunk of preparedObject.chunks) {
|
||||
if (streamSignal.aborted) {
|
||||
yield browserDataFailure("ABORTED", "OBJECT_READ");
|
||||
return;
|
||||
}
|
||||
const result = await invoke(
|
||||
"OBJECT_READ",
|
||||
{
|
||||
kind: "READ_CHUNK",
|
||||
preparedObject,
|
||||
sequence: chunk.sequence,
|
||||
},
|
||||
streamSignal,
|
||||
[],
|
||||
(value) => (value instanceof ArrayBuffer ? value : null),
|
||||
);
|
||||
if (!result.ok) {
|
||||
yield result;
|
||||
return;
|
||||
}
|
||||
yield browserDataSuccess(new Uint8Array(result.value));
|
||||
}
|
||||
},
|
||||
});
|
||||
return browserDataSuccess(source);
|
||||
},
|
||||
|
||||
async removeObject(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
objectId: string,
|
||||
generation: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke<void>(
|
||||
"OBJECT_DELETE",
|
||||
{ kind: "REMOVE_OBJECT", scope, objectId, generation },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
async cleanupTransaction(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke<void>(
|
||||
"OBJECT_RECONCILE",
|
||||
{ kind: "CLEANUP_TRANSACTION", scope, transactionId },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
async finalizePut(
|
||||
transactionId: string,
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke<void>(
|
||||
"OBJECT_RECONCILE",
|
||||
{ kind: "FINALIZE_PUT", transactionId, preparedObject },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
async listOrphanCandidates(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
olderThanEpochMs: number,
|
||||
maxEntries: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke(
|
||||
"OBJECT_RECONCILE",
|
||||
{
|
||||
kind: "LIST_ORPHAN_CANDIDATES",
|
||||
scope,
|
||||
olderThanEpochMs,
|
||||
maxEntries,
|
||||
},
|
||||
signal,
|
||||
[],
|
||||
parseOrphanCandidateBatch,
|
||||
);
|
||||
},
|
||||
|
||||
async deleteOrphanChunk(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
digestHex: string,
|
||||
olderThanEpochMs: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke(
|
||||
"OBJECT_RECONCILE",
|
||||
{
|
||||
kind: "DELETE_ORPHAN_CHUNK",
|
||||
scope,
|
||||
digestHex,
|
||||
olderThanEpochMs,
|
||||
},
|
||||
signal,
|
||||
[],
|
||||
parseOrphanDeleteResult,
|
||||
);
|
||||
},
|
||||
|
||||
close() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
dependencies.worker.removeEventListener("message", onMessage);
|
||||
dependencies.worker.removeFailureEventListener?.(onWorkerFailure);
|
||||
rejectAllPending();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createOwnedOpfsWorkerClient(
|
||||
dependencies: Readonly<{
|
||||
workerUrl: string | URL;
|
||||
policy: OpfsRuntimePolicy;
|
||||
workerName?: string;
|
||||
createRequestId?: () => string;
|
||||
}>,
|
||||
): OwnedOpfsWorkerClient {
|
||||
const worker = new Worker(dependencies.workerUrl, {
|
||||
type: "module",
|
||||
name: dependencies.workerName ?? "ca-opfs-byte-store",
|
||||
});
|
||||
const workerPort: OpfsWorkerLike = {
|
||||
postMessage: (message, transfer) =>
|
||||
worker.postMessage(message, transfer ? [...transfer] : []),
|
||||
addEventListener: (_type, listener) =>
|
||||
worker.addEventListener("message", listener),
|
||||
removeEventListener: (_type, listener) =>
|
||||
worker.removeEventListener("message", listener),
|
||||
addFailureEventListener: (listener) => {
|
||||
worker.addEventListener("error", listener);
|
||||
worker.addEventListener("messageerror", listener);
|
||||
},
|
||||
removeFailureEventListener: (listener) => {
|
||||
worker.removeEventListener("error", listener);
|
||||
worker.removeEventListener("messageerror", listener);
|
||||
},
|
||||
};
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker: workerPort,
|
||||
policy: dependencies.policy,
|
||||
createRequestId: dependencies.createRequestId,
|
||||
});
|
||||
return Object.freeze({
|
||||
gateway,
|
||||
terminate: () => {
|
||||
gateway.close();
|
||||
worker.terminate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function* rechunk(
|
||||
source: AsyncIterable<BrowserDataResult<Uint8Array>>,
|
||||
chunkSize: number,
|
||||
maxBytes: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): AsyncGenerator<ArrayBuffer> {
|
||||
let target = new Uint8Array(chunkSize);
|
||||
let targetOffset = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for await (const sourceResult of source) {
|
||||
if (signal?.aborted) throw new OpfsRpcError("ABORTED");
|
||||
if (!sourceResult.ok) {
|
||||
throw new OpfsRpcError(sourceResult.error.code);
|
||||
}
|
||||
const sourceChunk = sourceResult.value;
|
||||
if (!(sourceChunk instanceof Uint8Array)) {
|
||||
throw new OpfsRpcError("CORRUPT_DATA");
|
||||
}
|
||||
let sourceOffset = 0;
|
||||
totalBytes += sourceChunk.byteLength;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > maxBytes) {
|
||||
throw new OpfsRpcError("LIMIT_EXCEEDED");
|
||||
}
|
||||
while (sourceOffset < sourceChunk.byteLength) {
|
||||
const copyLength = Math.min(
|
||||
chunkSize - targetOffset,
|
||||
sourceChunk.byteLength - sourceOffset,
|
||||
);
|
||||
target.set(
|
||||
sourceChunk.subarray(sourceOffset, sourceOffset + copyLength),
|
||||
targetOffset,
|
||||
);
|
||||
sourceOffset += copyLength;
|
||||
targetOffset += copyLength;
|
||||
if (targetOffset === chunkSize) {
|
||||
yield target.buffer as ArrayBuffer;
|
||||
target = new Uint8Array(chunkSize);
|
||||
targetOffset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetOffset > 0) {
|
||||
yield target.slice(0, targetOffset).buffer as ArrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredSignal(signal: AbortSignal | undefined): AbortSignal {
|
||||
return signal ?? new AbortController().signal;
|
||||
}
|
||||
|
||||
function notifyProgress(
|
||||
request: PreparePhysicalObjectRequest,
|
||||
phase: "TRANSFERRING" | "VERIFYING",
|
||||
transferredBytes: number,
|
||||
): void {
|
||||
try {
|
||||
request.onProgress?.({
|
||||
phase,
|
||||
transferredBytes,
|
||||
totalBytes: request.descriptor.byteLength,
|
||||
});
|
||||
} catch {
|
||||
// A UI callback cannot affect the write protocol.
|
||||
}
|
||||
}
|
||||
|
||||
function failureResult(
|
||||
code: BrowserDataFailureCode,
|
||||
operation: BrowserDataOperation,
|
||||
): BrowserDataResult<never> {
|
||||
if (code === "ABORTED") return browserDataFailure(code, operation);
|
||||
if (code === "QUOTA_EXCEEDED") {
|
||||
return browserDataFailure(code, operation, {
|
||||
retryable: true,
|
||||
recovery: "READ_ONLY",
|
||||
});
|
||||
}
|
||||
if (code === "INTEGRITY_FAILED" || code === "CORRUPT_DATA") {
|
||||
return browserDataFailure(code, operation, { recovery: "REHYDRATE" });
|
||||
}
|
||||
if (code === "UNSUPPORTED" || code === "UNAVAILABLE") {
|
||||
return browserDataFailure(code, operation, {
|
||||
retryable: code === "UNAVAILABLE",
|
||||
recovery: "ONLINE_ONLY",
|
||||
});
|
||||
}
|
||||
return browserDataFailure(code, operation, {
|
||||
retryable: code === "BLOCKED" || code === "NOT_READABLE",
|
||||
recovery: code === "NOT_FOUND" ? "REHYDRATE" : "RETRY",
|
||||
});
|
||||
}
|
||||
|
||||
function parseCapabilities(value: unknown): OpfsCapabilities | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("available" in value) ||
|
||||
typeof value.available !== "boolean" ||
|
||||
!("dedicatedWorkerRequired" in value) ||
|
||||
value.dedicatedWorkerRequired !== true ||
|
||||
!("crossContextMutationLockAvailable" in value) ||
|
||||
typeof value.crossContextMutationLockAvailable !== "boolean" ||
|
||||
!("synchronousAccessHandleAvailable" in value) ||
|
||||
typeof value.synchronousAccessHandleAvailable !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as OpfsCapabilities;
|
||||
}
|
||||
|
||||
function parsePreparedObject(value: unknown): OpfsPreparedObject | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("physicalSchemaVersion" in value) ||
|
||||
value.physicalSchemaVersion !== 1 ||
|
||||
!("descriptor" in value) ||
|
||||
!("chunks" in value) ||
|
||||
!Array.isArray(value.chunks)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as OpfsPreparedObject;
|
||||
}
|
||||
|
||||
function parseOrphanCandidateBatch(
|
||||
value: unknown,
|
||||
): OpfsOrphanCandidateBatch | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("safeToSweep" in value) ||
|
||||
typeof value.safeToSweep !== "boolean" ||
|
||||
!("digests" in value) ||
|
||||
!Array.isArray(value.digests) ||
|
||||
!value.digests.every(
|
||||
(digest) =>
|
||||
typeof digest === "string" && /^[a-f0-9]{64}$/u.test(digest),
|
||||
) ||
|
||||
!("moreAvailable" in value) ||
|
||||
typeof value.moreAvailable !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as OpfsOrphanCandidateBatch;
|
||||
}
|
||||
|
||||
function parseOrphanDeleteResult(
|
||||
value: unknown,
|
||||
): OpfsOrphanDeleteResult | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("deleted" in value) ||
|
||||
typeof value.deleted !== "boolean" ||
|
||||
!("skippedUnsafe" in value) ||
|
||||
typeof value.skippedUnsafe !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as OpfsOrphanDeleteResult;
|
||||
}
|
||||
|
||||
function isWorkerResponse(value: unknown): value is OpfsWorkerResponse {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"requestId" in value &&
|
||||
typeof value.requestId === "string" &&
|
||||
"ok" in value &&
|
||||
typeof value.ok === "boolean",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
DurableObjectDescriptor,
|
||||
OpfsCapabilities,
|
||||
OpfsPreparedObject,
|
||||
OpfsStorageScope,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataResult,
|
||||
BrowserStoragePolicy,
|
||||
ByteSource,
|
||||
TransferProgress,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
export type OpfsWorkerRequest =
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "CAPABILITIES";
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "BEGIN_PUT";
|
||||
transactionId: string;
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
generation: number;
|
||||
declaredByteLength: number;
|
||||
mediaType: string;
|
||||
createdAtEpochMs: number;
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
chunkSizeBytes: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "APPEND_CHUNK";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
sequence: number;
|
||||
bytes: ArrayBuffer;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "FINISH_PUT";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "ABORT_PUT";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "VERIFY_OBJECT";
|
||||
preparedObject: OpfsPreparedObject;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "READ_CHUNK";
|
||||
preparedObject: OpfsPreparedObject;
|
||||
sequence: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "REMOVE_OBJECT";
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
generation: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "CLEANUP_TRANSACTION";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "FINALIZE_PUT";
|
||||
transactionId: string;
|
||||
preparedObject: OpfsPreparedObject;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "LIST_ORPHAN_CANDIDATES";
|
||||
scope: OpfsStorageScope;
|
||||
olderThanEpochMs: number;
|
||||
maxEntries: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
kind: "DELETE_ORPHAN_CHUNK";
|
||||
scope: OpfsStorageScope;
|
||||
digestHex: string;
|
||||
olderThanEpochMs: number;
|
||||
}>;
|
||||
|
||||
export type OpfsWorkerRequestBody =
|
||||
OpfsWorkerRequest extends infer Request
|
||||
? Request extends OpfsWorkerRequest
|
||||
? Omit<Request, "requestId">
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type OpfsWorkerFailure = Readonly<{
|
||||
code: BrowserDataFailureCode;
|
||||
retryable: boolean;
|
||||
}>;
|
||||
|
||||
export type OpfsOrphanCandidateBatch = Readonly<{
|
||||
safeToSweep: boolean;
|
||||
digests: readonly string[];
|
||||
moreAvailable: boolean;
|
||||
}>;
|
||||
|
||||
export type OpfsOrphanDeleteResult = Readonly<{
|
||||
deleted: boolean;
|
||||
skippedUnsafe: boolean;
|
||||
}>;
|
||||
|
||||
export type OpfsWorkerResponse =
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
ok: true;
|
||||
value?:
|
||||
| OpfsCapabilities
|
||||
| OpfsPreparedObject
|
||||
| ArrayBuffer
|
||||
| boolean
|
||||
| OpfsOrphanCandidateBatch
|
||||
| OpfsOrphanDeleteResult;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
ok: false;
|
||||
failure: OpfsWorkerFailure;
|
||||
}>;
|
||||
|
||||
export type PreparePhysicalObjectRequest = Readonly<{
|
||||
transactionId: string;
|
||||
descriptor: Omit<DurableObjectDescriptor, "integrity">;
|
||||
source: ByteSource;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: TransferProgress) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The coordinator depends on this technology-neutral worker gateway. The
|
||||
* browser implementation below the boundary owns Worker, MessageEvent and
|
||||
* transferable ArrayBuffer instances.
|
||||
*/
|
||||
export interface OpfsWorkerGateway {
|
||||
capabilities(): Promise<BrowserDataResult<OpfsCapabilities>>;
|
||||
preparePut(
|
||||
request: PreparePhysicalObjectRequest,
|
||||
): Promise<BrowserDataResult<OpfsPreparedObject>>;
|
||||
verifyObject(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<boolean>>;
|
||||
openObject(
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<ByteSource>>;
|
||||
removeObject(
|
||||
scope: OpfsStorageScope,
|
||||
objectId: string,
|
||||
generation: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
cleanupTransaction(
|
||||
scope: OpfsStorageScope,
|
||||
transactionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
finalizePut(
|
||||
transactionId: string,
|
||||
preparedObject: OpfsPreparedObject,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
listOrphanCandidates(
|
||||
scope: OpfsStorageScope,
|
||||
olderThanEpochMs: number,
|
||||
maxEntries: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<OpfsOrphanCandidateBatch>>;
|
||||
deleteOrphanChunk(
|
||||
scope: OpfsStorageScope,
|
||||
digestHex: string,
|
||||
olderThanEpochMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<OpfsOrphanDeleteResult>>;
|
||||
close(): void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user