chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
@@ -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" };
}
}
+25
View File
@@ -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() {},
});
}
+53
View File
@@ -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
+236
View File
@@ -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