A durable write whose journal transaction could not be completed returned plain success with `SUCCEEDED` telemetry. The payload was committed but the transaction stayed `COMMITTED`, so the reconcile backlog and its quota pressure grew while every caller was told the write had settled. That is now a `RECONCILE` failure with the effect certainty preserved, and an unfinished delete is observed `DEGRADED` rather than clean. The worker seam lost causes in both directions. A bootstrap failure answered every request with kind `CAPABILITIES`, so the gateway read a kind mismatch and replaced the real `BLOCKED` or `QUOTA_EXCEEDED` with a generic `UNSUPPORTED`; the envelope's correlation is now captured once at the listener. On the client, the pending row and its timer were released before the reply was decoded, so a trap that threw inside the decoder left the public promise pending with nothing left to time it out, and a throwing `requestId` getter produced a timeout instead of a prompt protocol failure. Public cache staging handed its signal to each `Request` and called that ownership. A fetch that ignored it held the mutation lock forever, and a digest that finished after the abort still wrote both the asset and the activation marker — publishing a release nobody was waiting for. One terminal owner now covers the whole staging body and every await re-checks it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1972 lines
58 KiB
TypeScript
1972 lines
58 KiB
TypeScript
import type {
|
|
OpfsCapabilities,
|
|
OpfsChunkReference,
|
|
OpfsCleanupEffect,
|
|
OpfsPhysicalGenerationId,
|
|
OpfsPreparedObject,
|
|
OpfsStorageScope,
|
|
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
|
import {
|
|
assertValidStoragePolicy,
|
|
type BrowserDataFailureCode,
|
|
} from "../../../application/ports/browser-file-storage/shared.ts";
|
|
import {
|
|
resolveOpfsRuntimePolicy,
|
|
isValidOpfsStorageScope,
|
|
type OpfsRuntimePolicy,
|
|
} from "./opfs-policy.ts";
|
|
import {
|
|
OPFS_WORKER_PROTOCOL_VERSION,
|
|
} from "./opfs-worker-protocol.ts";
|
|
import type {
|
|
OpfsOrphanCandidateBatch,
|
|
OpfsOrphanDeleteResult,
|
|
OpfsWorkerFailure,
|
|
OpfsWorkerRequest,
|
|
OpfsWorkerResponse,
|
|
} from "./opfs-worker-protocol.ts";
|
|
|
|
export interface OpfsMutationLease {
|
|
release(): void;
|
|
}
|
|
|
|
export interface OpfsMutationLeaseManager {
|
|
acquire(signal?: AbortSignal): Promise<OpfsMutationLease>;
|
|
}
|
|
|
|
export interface OpfsWorkerMessageHost {
|
|
addEventListener(
|
|
type: "message",
|
|
listener: (event: MessageEvent<unknown>) => void,
|
|
): void;
|
|
postMessage(
|
|
message: OpfsWorkerResponse,
|
|
transfer?: readonly Transferable[],
|
|
): void;
|
|
}
|
|
|
|
export type BrowserOpfsWorkerDependencies = Readonly<{
|
|
storageManager: StorageManager;
|
|
lockManager?: LockManager;
|
|
crypto: Crypto;
|
|
dedicatedWorker: boolean;
|
|
supportsSynchronousAccessHandles: boolean;
|
|
policy?: Partial<OpfsRuntimePolicy>;
|
|
}>;
|
|
|
|
type ActivePut = {
|
|
readonly transactionId: string;
|
|
readonly scope: OpfsPreparedObject["descriptor"]["scope"];
|
|
readonly objectId: string;
|
|
readonly generation: number;
|
|
readonly physicalGenerationId: OpfsPhysicalGenerationId;
|
|
readonly declaredByteLength: number;
|
|
readonly mediaType: string;
|
|
readonly createdAtEpochMs: number;
|
|
readonly storagePolicy: OpfsPreparedObject["descriptor"]["storagePolicy"];
|
|
readonly chunkSizeBytes: number;
|
|
readonly lease: OpfsMutationLease;
|
|
readonly abortController: AbortController;
|
|
readonly chunks: OpfsChunkReference[];
|
|
operationTail: Promise<void>;
|
|
state: "ACTIVE" | "FINISHING" | "ABORTED";
|
|
totalBytes: number;
|
|
sawShortChunk: boolean;
|
|
};
|
|
|
|
type SyncAccessHandleLike = {
|
|
write(data: ArrayBufferView, options?: { at?: number }): number;
|
|
truncate(newSize: number): void;
|
|
flush(): void;
|
|
close(): void;
|
|
};
|
|
|
|
type SyncCapableFileHandle = FileSystemFileHandle & {
|
|
createSyncAccessHandle?: () => Promise<SyncAccessHandleLike>;
|
|
};
|
|
|
|
type LockManagerLike = {
|
|
request<Value>(
|
|
name: string,
|
|
options: Readonly<{ mode: "exclusive"; signal?: AbortSignal }>,
|
|
callback: (lock: unknown) => Promise<Value>,
|
|
): Promise<Value>;
|
|
};
|
|
|
|
class OpfsRuntimeFailure extends Error {
|
|
readonly code: BrowserDataFailureCode;
|
|
readonly retryable: boolean;
|
|
|
|
constructor(code: BrowserDataFailureCode, retryable = false) {
|
|
super("OPFS operation failed.");
|
|
this.name = "OpfsRuntimeFailure";
|
|
this.code = code;
|
|
this.retryable = retryable;
|
|
}
|
|
}
|
|
|
|
export interface OpfsWorkerRuntime {
|
|
handleRequest(request: unknown): Promise<OpfsWorkerResponse | null>;
|
|
}
|
|
|
|
export async function createBrowserOpfsWorkerRuntime(
|
|
dependencies: BrowserOpfsWorkerDependencies,
|
|
): Promise<OpfsWorkerRuntime> {
|
|
const policy = resolveOpfsRuntimePolicy(dependencies.policy);
|
|
const originRoot = await dependencies.storageManager.getDirectory();
|
|
const root = await originRoot.getDirectoryHandle(policy.rootDirectoryName, {
|
|
create: true,
|
|
});
|
|
const leaseManager = dependencies.lockManager
|
|
? createWebLockLeaseManager(
|
|
dependencies.lockManager as unknown as LockManagerLike,
|
|
policy.mutationLockName,
|
|
)
|
|
: null;
|
|
|
|
return createOpfsWorkerRuntime({
|
|
root,
|
|
crypto: dependencies.crypto,
|
|
policy,
|
|
leaseManager,
|
|
dedicatedWorker: dependencies.dedicatedWorker,
|
|
supportsSynchronousAccessHandles:
|
|
dependencies.supportsSynchronousAccessHandles,
|
|
});
|
|
}
|
|
|
|
export async function startBrowserOpfsDedicatedWorker(
|
|
host: OpfsWorkerMessageHost,
|
|
dependencies: Readonly<{
|
|
storageManager: StorageManager;
|
|
lockManager?: LockManager;
|
|
crypto: Crypto;
|
|
policy?: Partial<OpfsRuntimePolicy>;
|
|
}>,
|
|
): Promise<void> {
|
|
const supportsSynchronousAccessHandles =
|
|
typeof FileSystemFileHandle !== "undefined" &&
|
|
"createSyncAccessHandle" in FileSystemFileHandle.prototype;
|
|
const runtimePromise = createBrowserOpfsWorkerRuntime({
|
|
...dependencies,
|
|
dedicatedWorker: true,
|
|
supportsSynchronousAccessHandles,
|
|
});
|
|
// Install before awaiting OPFS initialization. Worker messages posted while
|
|
// getDirectory() is pending must not be dropped during bootstrap.
|
|
host.addEventListener("message", (event) => {
|
|
if (!hasRequestId(event.data)) return;
|
|
const requestData = event.data;
|
|
// NS-05. The envelope's correlation is captured once, up front. Answering a
|
|
// bootstrap failure with a default `CAPABILITIES` kind made the client see
|
|
// an expected-kind mismatch and overwrite the real cause — a `BLOCKED` or
|
|
// `QUOTA_EXCEEDED` outage was reported to operators as `UNSUPPORTED`.
|
|
const correlation = requestCorrelation(requestData);
|
|
void runtimePromise
|
|
.then((runtime) => runtime.handleRequest(requestData))
|
|
.then((response) => postWorkerResponse(host, response))
|
|
.catch((error: unknown) => {
|
|
host.postMessage(
|
|
failure(
|
|
correlation.requestId,
|
|
mapRuntimeFailure(error),
|
|
correlation.kind,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
await runtimePromise;
|
|
}
|
|
|
|
export function createOpfsWorkerRuntime(
|
|
dependencies: Readonly<{
|
|
root: FileSystemDirectoryHandle;
|
|
crypto: Crypto;
|
|
policy: OpfsRuntimePolicy;
|
|
leaseManager: OpfsMutationLeaseManager | null;
|
|
dedicatedWorker: boolean;
|
|
supportsSynchronousAccessHandles: boolean;
|
|
}>,
|
|
): OpfsWorkerRuntime {
|
|
const activePuts = new Map<string, ActivePut>();
|
|
const pendingBegins = new Map<string, AbortController>();
|
|
const cancellationTombstones = new Set<string>();
|
|
const requiredCapabilitiesAvailable =
|
|
dependencies.dedicatedWorker &&
|
|
dependencies.leaseManager !== null &&
|
|
(dependencies.supportsSynchronousAccessHandles ||
|
|
dependencies.policy.allowAsyncWritableChunkFallback);
|
|
const capabilities: OpfsCapabilities = Object.freeze({
|
|
available: requiredCapabilitiesAvailable,
|
|
dedicatedWorkerRequired: true,
|
|
crossContextMutationLockAvailable: dependencies.leaseManager !== null,
|
|
synchronousAccessHandleAvailable:
|
|
dependencies.dedicatedWorker &&
|
|
dependencies.supportsSynchronousAccessHandles,
|
|
});
|
|
|
|
return Object.freeze({
|
|
async handleRequest(request: unknown) {
|
|
if (!hasRequestId(request)) return null;
|
|
if (!isWorkerRequest(request)) {
|
|
// STO-RR-02. Only an envelope this runtime could not read produces a
|
|
// protocol-level failure. Everything below answers its own request.
|
|
return failure(
|
|
request.requestId,
|
|
mapRuntimeFailure(new OpfsRuntimeFailure("INVALID_INPUT")),
|
|
);
|
|
}
|
|
try {
|
|
switch (request.kind) {
|
|
case "CAPABILITIES":
|
|
return success(request.requestId, request.kind, capabilities);
|
|
case "BEGIN_PUT":
|
|
await beginPut(request);
|
|
return success(request.requestId, request.kind);
|
|
case "APPEND_CHUNK":
|
|
await appendChunk(request);
|
|
return success(request.requestId, request.kind);
|
|
case "FINISH_PUT":
|
|
return success(
|
|
request.requestId,
|
|
request.kind,
|
|
await finishPut(request),
|
|
);
|
|
case "ABORT_PUT":
|
|
return success(
|
|
request.requestId,
|
|
request.kind,
|
|
await abortPut(
|
|
request.scope,
|
|
request.transactionId,
|
|
request.physicalGenerationId,
|
|
),
|
|
);
|
|
case "VERIFY_OBJECT":
|
|
return success(
|
|
request.requestId,
|
|
request.kind,
|
|
await verifyObject(request.preparedObject),
|
|
);
|
|
case "READ_CHUNK":
|
|
return success(
|
|
request.requestId,
|
|
request.kind,
|
|
await readVerifiedChunk(
|
|
request.preparedObject,
|
|
request.sequence,
|
|
),
|
|
);
|
|
case "REMOVE_OBJECT":
|
|
await removeObject(
|
|
request.scope,
|
|
request.objectId,
|
|
request.generation,
|
|
);
|
|
return success(request.requestId, request.kind);
|
|
case "CLEANUP_TRANSACTION":
|
|
return success(
|
|
request.requestId,
|
|
request.kind,
|
|
await cleanupTransaction(
|
|
request.scope,
|
|
request.transactionId,
|
|
true,
|
|
request.physicalGenerationId,
|
|
),
|
|
);
|
|
case "FINALIZE_PUT":
|
|
await finalizePut(
|
|
request.transactionId,
|
|
request.preparedObject,
|
|
);
|
|
return success(request.requestId, request.kind);
|
|
case "LIST_ORPHAN_CANDIDATES":
|
|
return success(
|
|
request.requestId,
|
|
request.kind,
|
|
await listOrphanCandidates(
|
|
request.scope,
|
|
request.olderThanEpochMs,
|
|
request.maxEntries,
|
|
),
|
|
);
|
|
case "DELETE_ORPHAN_CHUNK":
|
|
return success(
|
|
request.requestId,
|
|
request.kind,
|
|
await deleteOrphanChunk(
|
|
request.scope,
|
|
request.digestHex,
|
|
request.olderThanEpochMs,
|
|
),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// STO-RR-02. The kind travels with the failure so the client's
|
|
// expected-kind check cannot mistake a quota, integrity or abort
|
|
// failure for a protocol breach.
|
|
return failure(
|
|
request.requestId,
|
|
mapRuntimeFailure(error),
|
|
request.kind,
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
async function beginPut(
|
|
request: Extract<OpfsWorkerRequest, { kind: "BEGIN_PUT" }>,
|
|
): Promise<void> {
|
|
assertWorkerAvailable();
|
|
if (
|
|
!SAFE_TRANSACTION_ID.test(request.transactionId) ||
|
|
!isValidOpfsStorageScope(request.scope) ||
|
|
!dependencies.policy.isObjectIdAllowed(request.objectId) ||
|
|
!Number.isSafeInteger(request.generation) ||
|
|
request.generation < 1 ||
|
|
!isPhysicalGenerationId(request.physicalGenerationId) ||
|
|
!Number.isSafeInteger(request.declaredByteLength) ||
|
|
request.declaredByteLength < 0 ||
|
|
request.declaredByteLength > dependencies.policy.maxObjectBytes ||
|
|
!dependencies.policy.isMediaTypeAllowed(request.mediaType) ||
|
|
!Number.isSafeInteger(request.createdAtEpochMs) ||
|
|
request.createdAtEpochMs < 0 ||
|
|
request.chunkSizeBytes !== dependencies.policy.chunkSizeBytes
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
try {
|
|
assertValidStoragePolicy(request.storagePolicy);
|
|
} catch {
|
|
throw new OpfsRuntimeFailure("POLICY_REJECTED");
|
|
}
|
|
if (request.storagePolicy.namespace !== request.scope.namespace) {
|
|
throw new OpfsRuntimeFailure("POLICY_REJECTED");
|
|
}
|
|
const transactionKey = scopedTransactionKey(
|
|
request.scope,
|
|
request.transactionId,
|
|
);
|
|
if (activePuts.has(transactionKey)) {
|
|
throw new OpfsRuntimeFailure("CONFLICT");
|
|
}
|
|
|
|
if (cancellationTombstones.has(transactionKey)) {
|
|
throw new OpfsRuntimeFailure("ABORTED");
|
|
}
|
|
const beginAbort = new AbortController();
|
|
pendingBegins.set(transactionKey, beginAbort);
|
|
let lease: OpfsMutationLease;
|
|
try {
|
|
lease = await dependencies.leaseManager!.acquire(beginAbort.signal);
|
|
} finally {
|
|
pendingBegins.delete(transactionKey);
|
|
}
|
|
if (
|
|
beginAbort.signal.aborted ||
|
|
cancellationTombstones.has(transactionKey)
|
|
) {
|
|
lease.release();
|
|
throw new OpfsRuntimeFailure("ABORTED");
|
|
}
|
|
const put: ActivePut = {
|
|
transactionId: request.transactionId,
|
|
scope: request.scope,
|
|
objectId: request.objectId,
|
|
generation: request.generation,
|
|
physicalGenerationId: request.physicalGenerationId,
|
|
declaredByteLength: request.declaredByteLength,
|
|
mediaType: request.mediaType,
|
|
createdAtEpochMs: request.createdAtEpochMs,
|
|
storagePolicy: request.storagePolicy,
|
|
chunkSizeBytes: request.chunkSizeBytes,
|
|
lease,
|
|
abortController: beginAbort,
|
|
chunks: [],
|
|
operationTail: Promise.resolve(),
|
|
state: "ACTIVE",
|
|
totalBytes: 0,
|
|
sawShortChunk: false,
|
|
};
|
|
activePuts.set(transactionKey, put);
|
|
try {
|
|
await writeReceipt(put);
|
|
if (
|
|
beginAbort.signal.aborted ||
|
|
cancellationTombstones.has(transactionKey)
|
|
) {
|
|
if (activePuts.get(transactionKey) === put) {
|
|
activePuts.delete(transactionKey);
|
|
lease.release();
|
|
}
|
|
await cleanupTransaction(request.scope, request.transactionId);
|
|
throw new OpfsRuntimeFailure("ABORTED");
|
|
}
|
|
} catch (error) {
|
|
if (activePuts.get(transactionKey) === put) {
|
|
activePuts.delete(transactionKey);
|
|
lease.release();
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function appendChunk(
|
|
request: Extract<OpfsWorkerRequest, { kind: "APPEND_CHUNK" }>,
|
|
): Promise<void> {
|
|
if (!isValidOpfsStorageScope(request.scope)) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const transactionKey = scopedTransactionKey(
|
|
request.scope,
|
|
request.transactionId,
|
|
);
|
|
const put = activePuts.get(transactionKey);
|
|
if (!put) throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
try {
|
|
await runActivePutOperation(transactionKey, put, async () => {
|
|
if (
|
|
request.sequence !== put.chunks.length ||
|
|
!(request.bytes instanceof ArrayBuffer) ||
|
|
request.bytes.byteLength === 0 ||
|
|
request.bytes.byteLength > put.chunkSizeBytes ||
|
|
put.sawShortChunk ||
|
|
put.chunks.length >= dependencies.policy.maxChunkCount
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const nextTotal = put.totalBytes + request.bytes.byteLength;
|
|
if (
|
|
!Number.isSafeInteger(nextTotal) ||
|
|
nextTotal > put.declaredByteLength ||
|
|
nextTotal > dependencies.policy.maxObjectBytes
|
|
) {
|
|
throw new OpfsRuntimeFailure("LIMIT_EXCEEDED");
|
|
}
|
|
|
|
const bytes = new Uint8Array(request.bytes);
|
|
const digestHex = await sha256Hex(dependencies.crypto, bytes);
|
|
assertActivePut(transactionKey, put);
|
|
await writeImmutableChunk(put.scope, digestHex, bytes);
|
|
assertActivePut(transactionKey, put);
|
|
put.chunks.push(
|
|
Object.freeze({
|
|
sequence: request.sequence,
|
|
byteLength: bytes.byteLength,
|
|
digestHex,
|
|
}),
|
|
);
|
|
put.totalBytes = nextTotal;
|
|
put.sawShortChunk = bytes.byteLength < put.chunkSizeBytes;
|
|
await writeReceipt(put);
|
|
assertActivePut(transactionKey, put);
|
|
});
|
|
} catch (error) {
|
|
if (put.state !== "ABORTED") {
|
|
await failActivePut(transactionKey, put);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function finishPut(
|
|
request: Extract<OpfsWorkerRequest, { kind: "FINISH_PUT" }>,
|
|
): Promise<OpfsPreparedObject> {
|
|
if (!isValidOpfsStorageScope(request.scope)) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const transactionKey = scopedTransactionKey(
|
|
request.scope,
|
|
request.transactionId,
|
|
);
|
|
const put = activePuts.get(transactionKey);
|
|
if (!put) throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
try {
|
|
const preparedObject = await runActivePutOperation(
|
|
transactionKey,
|
|
put,
|
|
async () => {
|
|
if (put.state !== "ACTIVE") {
|
|
throw new OpfsRuntimeFailure("CONFLICT");
|
|
}
|
|
put.state = "FINISHING";
|
|
if (
|
|
put.totalBytes !== put.declaredByteLength ||
|
|
put.chunks.length !==
|
|
Math.ceil(put.declaredByteLength / put.chunkSizeBytes)
|
|
) {
|
|
throw new OpfsRuntimeFailure("INTEGRITY_FAILED");
|
|
}
|
|
const rootDigestHex = await treeDigestHex(
|
|
dependencies.crypto,
|
|
put.chunkSizeBytes,
|
|
put.declaredByteLength,
|
|
put.chunks,
|
|
);
|
|
assertActivePut(transactionKey, put, true);
|
|
const prepared: OpfsPreparedObject = Object.freeze({
|
|
physicalSchemaVersion: 2,
|
|
physicalGenerationId: put.physicalGenerationId,
|
|
descriptor: Object.freeze({
|
|
objectId: put.objectId,
|
|
scope: put.scope,
|
|
generation: put.generation,
|
|
byteLength: put.declaredByteLength,
|
|
mediaType: put.mediaType,
|
|
createdAtEpochMs: put.createdAtEpochMs,
|
|
integrity: Object.freeze({
|
|
algorithm: "SHA-256-TREE-V1",
|
|
rootDigestHex,
|
|
chunkSizeBytes: put.chunkSizeBytes,
|
|
}),
|
|
storagePolicy: put.storagePolicy,
|
|
}),
|
|
chunks: Object.freeze([...put.chunks]),
|
|
});
|
|
await writeJsonAtomic(manifestPath(prepared), prepared);
|
|
assertActivePut(transactionKey, put, true);
|
|
await writeJsonAtomic(receiptPath(put.scope, put.transactionId), {
|
|
schemaVersion: 1,
|
|
phase: "FILES_READY",
|
|
preparedObject: prepared,
|
|
});
|
|
assertActivePut(transactionKey, put, true);
|
|
return prepared;
|
|
},
|
|
);
|
|
if (activePuts.get(transactionKey) === put) {
|
|
activePuts.delete(transactionKey);
|
|
put.lease.release();
|
|
}
|
|
return preparedObject;
|
|
} catch (error) {
|
|
if (put.state !== "ABORTED") {
|
|
await failActivePut(transactionKey, put);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function abortPut(
|
|
scope: OpfsPreparedObject["descriptor"]["scope"],
|
|
transactionId: string,
|
|
physicalGenerationId?: OpfsPhysicalGenerationId,
|
|
): Promise<OpfsCleanupEffect> {
|
|
if (
|
|
!isValidOpfsStorageScope(scope) ||
|
|
!SAFE_TRANSACTION_ID.test(transactionId) ||
|
|
(physicalGenerationId !== undefined &&
|
|
!isPhysicalGenerationId(physicalGenerationId))
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const transactionKey = scopedTransactionKey(scope, transactionId);
|
|
rememberCancellation(transactionKey);
|
|
pendingBegins.get(transactionKey)?.abort();
|
|
const active = activePuts.get(transactionKey);
|
|
if (active) {
|
|
active.state = "ABORTED";
|
|
active.abortController.abort();
|
|
await active.operationTail;
|
|
if (activePuts.get(transactionKey) === active) {
|
|
activePuts.delete(transactionKey);
|
|
}
|
|
try {
|
|
// STO-01. The mutation lease is held through the physical delete and
|
|
// the staging cleanup; releasing it earlier would let a new transaction
|
|
// race this compensation.
|
|
await removePhysicalGeneration(
|
|
active.scope,
|
|
active.objectId,
|
|
active.generation,
|
|
active.physicalGenerationId,
|
|
);
|
|
return await cleanupTransactionLocked(
|
|
scope,
|
|
transactionId,
|
|
true,
|
|
physicalGenerationId ?? active.physicalGenerationId,
|
|
);
|
|
} finally {
|
|
active.lease.release();
|
|
}
|
|
}
|
|
return await cleanupTransaction(
|
|
scope,
|
|
transactionId,
|
|
true,
|
|
physicalGenerationId,
|
|
);
|
|
}
|
|
|
|
async function runActivePutOperation<Value>(
|
|
transactionKey: string,
|
|
put: ActivePut,
|
|
operation: () => Promise<Value>,
|
|
): Promise<Value> {
|
|
const previous = put.operationTail;
|
|
let release: (() => void) | undefined;
|
|
put.operationTail = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await previous;
|
|
try {
|
|
assertActivePut(transactionKey, put, true);
|
|
return await operation();
|
|
} finally {
|
|
release?.();
|
|
}
|
|
}
|
|
|
|
function assertActivePut(
|
|
transactionKey: string,
|
|
put: ActivePut,
|
|
allowFinishing = false,
|
|
): void {
|
|
if (
|
|
activePuts.get(transactionKey) !== put ||
|
|
put.abortController.signal.aborted ||
|
|
put.state === "ABORTED" ||
|
|
(!allowFinishing && put.state !== "ACTIVE")
|
|
) {
|
|
throw new OpfsRuntimeFailure("ABORTED");
|
|
}
|
|
}
|
|
|
|
async function failActivePut(
|
|
transactionKey: string,
|
|
put: ActivePut,
|
|
): Promise<void> {
|
|
put.state = "ABORTED";
|
|
put.abortController.abort();
|
|
if (activePuts.get(transactionKey) === put) {
|
|
activePuts.delete(transactionKey);
|
|
}
|
|
try {
|
|
await removePhysicalGeneration(
|
|
put.scope,
|
|
put.objectId,
|
|
put.generation,
|
|
put.physicalGenerationId,
|
|
);
|
|
await cleanupTransactionLocked(
|
|
put.scope,
|
|
put.transactionId,
|
|
true,
|
|
put.physicalGenerationId,
|
|
);
|
|
} finally {
|
|
put.lease.release();
|
|
}
|
|
}
|
|
|
|
async function removePhysicalGeneration(
|
|
scope: OpfsStorageScope,
|
|
objectId: string,
|
|
generation: number,
|
|
physicalGenerationId: OpfsPhysicalGenerationId | undefined,
|
|
): Promise<void> {
|
|
try {
|
|
const objectDirectory = await getDirectory(
|
|
dependencies.root,
|
|
[
|
|
...scopeRootPath(scope),
|
|
"objects",
|
|
objectId.slice(0, 2),
|
|
objectId,
|
|
],
|
|
false,
|
|
);
|
|
await removeEntryIfPresent(
|
|
objectDirectory,
|
|
generationSegmentFor(generation, physicalGenerationId),
|
|
true,
|
|
);
|
|
} catch (error) {
|
|
if (!isNotFound(error)) throw error;
|
|
}
|
|
}
|
|
|
|
async function verifyObject(
|
|
expected: OpfsPreparedObject,
|
|
): Promise<boolean> {
|
|
if (!isPreparedObjectSafe(expected, dependencies.policy)) return false;
|
|
let stored: unknown;
|
|
try {
|
|
stored = await readJson(manifestPath(expected));
|
|
} catch (error) {
|
|
if (isNotFound(error)) return false;
|
|
throw error;
|
|
}
|
|
if (
|
|
!isPreparedObjectSafe(stored, dependencies.policy) ||
|
|
stableJson(stored) !== stableJson(expected)
|
|
) {
|
|
return false;
|
|
}
|
|
const rootDigestHex = await treeDigestHex(
|
|
dependencies.crypto,
|
|
expected.descriptor.integrity.chunkSizeBytes,
|
|
expected.descriptor.byteLength,
|
|
expected.chunks,
|
|
);
|
|
if (rootDigestHex !== expected.descriptor.integrity.rootDigestHex) {
|
|
return false;
|
|
}
|
|
for (const chunk of expected.chunks) {
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = await readFile(
|
|
chunkPath(expected.descriptor.scope, chunk.digestHex),
|
|
);
|
|
} catch (error) {
|
|
if (isNotFound(error)) return false;
|
|
throw error;
|
|
}
|
|
if (
|
|
bytes.byteLength !== chunk.byteLength ||
|
|
(await sha256Hex(dependencies.crypto, bytes)) !== chunk.digestHex
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function readVerifiedChunk(
|
|
expected: OpfsPreparedObject,
|
|
sequence: number,
|
|
): Promise<ArrayBuffer> {
|
|
if (
|
|
!isPreparedObjectSafe(expected, dependencies.policy) ||
|
|
!Number.isSafeInteger(sequence) ||
|
|
sequence < 0
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const reference = expected.chunks[sequence];
|
|
if (!reference || reference.sequence !== sequence) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const bytes = await readFile(
|
|
chunkPath(expected.descriptor.scope, reference.digestHex),
|
|
);
|
|
if (
|
|
bytes.byteLength !== reference.byteLength ||
|
|
(await sha256Hex(dependencies.crypto, bytes)) !== reference.digestHex
|
|
) {
|
|
throw new OpfsRuntimeFailure("INTEGRITY_FAILED");
|
|
}
|
|
const copy = Uint8Array.from(bytes);
|
|
return copy.buffer as ArrayBuffer;
|
|
}
|
|
|
|
async function removeObject(
|
|
scope: OpfsPreparedObject["descriptor"]["scope"],
|
|
objectId: string,
|
|
generation: number,
|
|
): Promise<void> {
|
|
assertWorkerAvailable();
|
|
if (
|
|
!isValidOpfsStorageScope(scope) ||
|
|
!dependencies.policy.isObjectIdAllowed(objectId) ||
|
|
!Number.isSafeInteger(generation) ||
|
|
generation < 1
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const lease = await dependencies.leaseManager!.acquire();
|
|
try {
|
|
const prefixDirectory = await getDirectory(
|
|
dependencies.root,
|
|
[
|
|
...scopeRootPath(scope),
|
|
"objects",
|
|
objectId.slice(0, 2),
|
|
],
|
|
false,
|
|
);
|
|
await removeEntryIfPresent(prefixDirectory, objectId, true);
|
|
} catch (error) {
|
|
if (!isNotFound(error)) throw error;
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* STO-01. Cleanup deletes exactly one transaction's physical generation while
|
|
* holding the origin mutation lease, and reports whether the effect actually
|
|
* happened. `ALREADY_CLEAN` means there was nothing left to delete.
|
|
*/
|
|
async function cleanupTransaction(
|
|
scope: OpfsPreparedObject["descriptor"]["scope"],
|
|
transactionId: string,
|
|
removePreparedGeneration = true,
|
|
physicalGenerationId?: OpfsPhysicalGenerationId,
|
|
): Promise<OpfsCleanupEffect> {
|
|
if (
|
|
!isValidOpfsStorageScope(scope) ||
|
|
!SAFE_TRANSACTION_ID.test(transactionId)
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
// Probe before locking. A transaction that never reached staging has
|
|
// nothing to delete, and waiting for the mutation lease here would deadlock
|
|
// against the very BEGIN this compensation is cancelling.
|
|
try {
|
|
await getDirectory(
|
|
dependencies.root,
|
|
[...scopeRootPath(scope), "staging", transactionId],
|
|
false,
|
|
);
|
|
} catch (error) {
|
|
if (isNotFound(error)) return CLEANUP_ALREADY_CLEAN;
|
|
throw error;
|
|
}
|
|
// Every destructive step below runs while the lease is held.
|
|
const lease = await dependencies.leaseManager!.acquire();
|
|
try {
|
|
return await cleanupTransactionLocked(
|
|
scope,
|
|
transactionId,
|
|
removePreparedGeneration,
|
|
physicalGenerationId,
|
|
);
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Callers that already hold the origin mutation lease use this directly, so
|
|
* an abort never releases the lease between fencing and physical deletion.
|
|
*/
|
|
async function cleanupTransactionLocked(
|
|
scope: OpfsPreparedObject["descriptor"]["scope"],
|
|
transactionId: string,
|
|
removePreparedGeneration = true,
|
|
physicalGenerationId?: OpfsPhysicalGenerationId,
|
|
): Promise<OpfsCleanupEffect> {
|
|
if (
|
|
!isValidOpfsStorageScope(scope) ||
|
|
!SAFE_TRANSACTION_ID.test(transactionId) ||
|
|
(physicalGenerationId !== undefined &&
|
|
!isPhysicalGenerationId(physicalGenerationId))
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
let staging: FileSystemDirectoryHandle;
|
|
try {
|
|
staging = await getDirectory(
|
|
dependencies.root,
|
|
[...scopeRootPath(scope), "staging"],
|
|
false,
|
|
);
|
|
} catch (error) {
|
|
if (isNotFound(error)) return CLEANUP_ALREADY_CLEAN;
|
|
throw error;
|
|
}
|
|
{
|
|
if (removePreparedGeneration) {
|
|
let receipt: unknown;
|
|
try {
|
|
receipt = await readJson(receiptPath(scope, transactionId));
|
|
} catch (error) {
|
|
if (isNotFound(error)) {
|
|
await removeEntryIfPresent(staging, transactionId, true);
|
|
return CLEANUP_ALREADY_CLEAN;
|
|
}
|
|
// Keep unreadable staging in place so orphan GC fails closed.
|
|
throw error;
|
|
}
|
|
const target = extractReceiptPhysicalTarget(receipt, scope);
|
|
if (!target) {
|
|
throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
|
}
|
|
// A caller-supplied token wins: a stale compensation must not widen its
|
|
// target to whatever the receipt now says.
|
|
await removePhysicalGeneration(
|
|
scope,
|
|
target.objectId,
|
|
target.generation,
|
|
physicalGenerationId ?? target.physicalGenerationId,
|
|
);
|
|
}
|
|
await removeEntryIfPresent(staging, transactionId, true);
|
|
return CLEANUP_CLEANED;
|
|
}
|
|
}
|
|
|
|
async function finalizePut(
|
|
transactionId: string,
|
|
preparedObject: OpfsPreparedObject,
|
|
): Promise<void> {
|
|
assertWorkerAvailable();
|
|
if (
|
|
!SAFE_TRANSACTION_ID.test(transactionId) ||
|
|
!isPreparedObjectSafe(preparedObject, dependencies.policy)
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const lease = await dependencies.leaseManager!.acquire();
|
|
try {
|
|
const descriptor = preparedObject.descriptor;
|
|
const objectDirectory = await getDirectory(
|
|
dependencies.root,
|
|
[
|
|
...scopeRootPath(descriptor.scope),
|
|
"objects",
|
|
descriptor.objectId.slice(0, 2),
|
|
descriptor.objectId,
|
|
],
|
|
false,
|
|
);
|
|
for await (const [name, handle] of objectDirectory.entries()) {
|
|
if (
|
|
handle.kind === "directory" &&
|
|
isGenerationSegment(name) &&
|
|
name !== preparedGenerationSegment(preparedObject)
|
|
) {
|
|
await objectDirectory.removeEntry(name, { recursive: true });
|
|
}
|
|
}
|
|
// STO-RR-01. This path already holds the origin mutation lease, and a
|
|
// Web Lock is not reentrant: asking for it again here never returns, so
|
|
// an ordinary PUT would stop for good at FINALIZE.
|
|
await cleanupTransactionLocked(descriptor.scope, transactionId, false);
|
|
} catch (error) {
|
|
if (!isNotFound(error)) throw error;
|
|
await cleanupTransactionLocked(
|
|
preparedObject.descriptor.scope,
|
|
transactionId,
|
|
false,
|
|
);
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
}
|
|
|
|
async function listOrphanCandidates(
|
|
scope: OpfsPreparedObject["descriptor"]["scope"],
|
|
olderThanEpochMs: number,
|
|
maxEntries: number,
|
|
): Promise<OpfsOrphanCandidateBatch> {
|
|
assertWorkerAvailable();
|
|
if (
|
|
!isValidOpfsStorageScope(scope) ||
|
|
!Number.isSafeInteger(olderThanEpochMs) ||
|
|
olderThanEpochMs < 0 ||
|
|
!Number.isSafeInteger(maxEntries) ||
|
|
maxEntries < 1 ||
|
|
maxEntries > dependencies.policy.orphanGcBatchSize
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const lease = await dependencies.leaseManager!.acquire();
|
|
try {
|
|
const staged = await stagedChunkDigests(scope);
|
|
if (!staged.safeToSweep) {
|
|
return Object.freeze({
|
|
safeToSweep: false,
|
|
digests: Object.freeze([]),
|
|
moreAvailable: false,
|
|
});
|
|
}
|
|
let shaRoot: FileSystemDirectoryHandle;
|
|
try {
|
|
shaRoot = await getDirectory(
|
|
dependencies.root,
|
|
[...scopeRootPath(scope), "chunks", "sha256"],
|
|
false,
|
|
);
|
|
} catch (error) {
|
|
if (isNotFound(error)) {
|
|
return Object.freeze({
|
|
safeToSweep: true,
|
|
digests: Object.freeze([]),
|
|
moreAvailable: false,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
const candidates: string[] = [];
|
|
outer: for await (const [, prefixHandle] of shaRoot.entries()) {
|
|
if (prefixHandle.kind !== "directory") continue;
|
|
const prefixDirectory =
|
|
prefixHandle as FileSystemDirectoryHandle;
|
|
for await (const [name, handle] of prefixDirectory.entries()) {
|
|
if (handle.kind !== "file") continue;
|
|
const digestHex = name.endsWith(".bin")
|
|
? name.slice(0, -4)
|
|
: "";
|
|
if (
|
|
!SHA256_HEX.test(digestHex) ||
|
|
staged.digests.has(digestHex)
|
|
) {
|
|
continue;
|
|
}
|
|
const file = await (
|
|
handle as FileSystemFileHandle
|
|
).getFile();
|
|
if (file.lastModified > olderThanEpochMs) continue;
|
|
candidates.push(digestHex);
|
|
if (candidates.length > maxEntries) break outer;
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
safeToSweep: true,
|
|
digests: Object.freeze(candidates.slice(0, maxEntries)),
|
|
moreAvailable: candidates.length > maxEntries,
|
|
});
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
}
|
|
|
|
async function deleteOrphanChunk(
|
|
scope: OpfsPreparedObject["descriptor"]["scope"],
|
|
digestHex: string,
|
|
olderThanEpochMs: number,
|
|
): Promise<OpfsOrphanDeleteResult> {
|
|
assertWorkerAvailable();
|
|
if (
|
|
!isValidOpfsStorageScope(scope) ||
|
|
!SHA256_HEX.test(digestHex) ||
|
|
!Number.isSafeInteger(olderThanEpochMs) ||
|
|
olderThanEpochMs < 0
|
|
) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const lease = await dependencies.leaseManager!.acquire();
|
|
try {
|
|
const staged = await stagedChunkDigests(scope);
|
|
if (!staged.safeToSweep) {
|
|
return Object.freeze({ deleted: false, skippedUnsafe: true });
|
|
}
|
|
if (staged.digests.has(digestHex)) {
|
|
return Object.freeze({ deleted: false, skippedUnsafe: false });
|
|
}
|
|
const path = chunkPath(scope, digestHex);
|
|
try {
|
|
const { directory, fileName } = await resolveFileParent(
|
|
dependencies.root,
|
|
path,
|
|
false,
|
|
);
|
|
const handle = await directory.getFileHandle(fileName);
|
|
const file = await handle.getFile();
|
|
if (file.lastModified > olderThanEpochMs) {
|
|
return Object.freeze({ deleted: false, skippedUnsafe: false });
|
|
}
|
|
await directory.removeEntry(fileName);
|
|
return Object.freeze({ deleted: true, skippedUnsafe: false });
|
|
} catch (error) {
|
|
if (isNotFound(error)) {
|
|
return Object.freeze({ deleted: false, skippedUnsafe: false });
|
|
}
|
|
throw error;
|
|
}
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
}
|
|
|
|
async function stagedChunkDigests(
|
|
scope: OpfsPreparedObject["descriptor"]["scope"],
|
|
): Promise<Readonly<{
|
|
safeToSweep: boolean;
|
|
digests: ReadonlySet<string>;
|
|
}>> {
|
|
const digests = new Set<string>();
|
|
let staging: FileSystemDirectoryHandle;
|
|
try {
|
|
staging = await getDirectory(
|
|
dependencies.root,
|
|
[...scopeRootPath(scope), "staging"],
|
|
false,
|
|
);
|
|
} catch (error) {
|
|
if (isNotFound(error)) {
|
|
return { safeToSweep: true, digests };
|
|
}
|
|
throw error;
|
|
}
|
|
for await (const [transactionId, handle] of staging.entries()) {
|
|
if (
|
|
handle.kind !== "directory" ||
|
|
!SAFE_TRANSACTION_ID.test(transactionId)
|
|
) {
|
|
return { safeToSweep: false, digests };
|
|
}
|
|
let receipt: unknown;
|
|
try {
|
|
receipt = await readJson(receiptPath(scope, transactionId));
|
|
} catch {
|
|
return { safeToSweep: false, digests };
|
|
}
|
|
if (!receiptBelongsToScope(receipt, scope)) {
|
|
return { safeToSweep: false, digests };
|
|
}
|
|
const receiptDigests = extractReceiptDigests(receipt);
|
|
if (!receiptDigests) {
|
|
return { safeToSweep: false, digests };
|
|
}
|
|
for (const digest of receiptDigests) digests.add(digest);
|
|
}
|
|
return { safeToSweep: true, digests };
|
|
}
|
|
|
|
function extractReceiptPhysicalTarget(
|
|
receipt: unknown,
|
|
scope: OpfsStorageScope,
|
|
): Readonly<{
|
|
objectId: string;
|
|
generation: number;
|
|
physicalGenerationId: OpfsPhysicalGenerationId | undefined;
|
|
}> | null {
|
|
if (!receiptBelongsToScope(receipt, scope)) return null;
|
|
const record = receipt as Record<string, unknown>;
|
|
if (record.phase === "PREPARING") {
|
|
return typeof record.objectId === "string" &&
|
|
dependencies.policy.isObjectIdAllowed(record.objectId) &&
|
|
typeof record.generation === "number" &&
|
|
Number.isSafeInteger(record.generation) &&
|
|
record.generation > 0
|
|
? {
|
|
objectId: record.objectId,
|
|
generation: record.generation,
|
|
physicalGenerationId: isPhysicalGenerationId(
|
|
record.physicalGenerationId,
|
|
)
|
|
? record.physicalGenerationId
|
|
: undefined,
|
|
}
|
|
: null;
|
|
}
|
|
if (
|
|
record.phase === "FILES_READY" &&
|
|
isPreparedObjectSafe(record.preparedObject, dependencies.policy) &&
|
|
sameScope(record.preparedObject.descriptor.scope, scope)
|
|
) {
|
|
const prepared = record.preparedObject;
|
|
return {
|
|
objectId: prepared.descriptor.objectId,
|
|
generation: prepared.descriptor.generation,
|
|
physicalGenerationId:
|
|
prepared.physicalSchemaVersion === 2
|
|
? prepared.physicalGenerationId
|
|
: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rememberCancellation(transactionId: string): void {
|
|
cancellationTombstones.add(transactionId);
|
|
while (
|
|
cancellationTombstones.size >
|
|
dependencies.policy.maxCancellationTombstones
|
|
) {
|
|
const oldest = cancellationTombstones.values().next().value;
|
|
if (typeof oldest !== "string") break;
|
|
cancellationTombstones.delete(oldest);
|
|
}
|
|
}
|
|
|
|
async function writeReceipt(put: ActivePut): Promise<void> {
|
|
await writeJsonAtomic(receiptPath(put.scope, put.transactionId), {
|
|
schemaVersion: 2,
|
|
phase: "PREPARING",
|
|
scope: put.scope,
|
|
objectId: put.objectId,
|
|
generation: put.generation,
|
|
physicalGenerationId: put.physicalGenerationId,
|
|
declaredByteLength: put.declaredByteLength,
|
|
chunks: put.chunks,
|
|
});
|
|
}
|
|
|
|
async function writeImmutableChunk(
|
|
scope: OpfsPreparedObject["descriptor"]["scope"],
|
|
digestHex: string,
|
|
bytes: Uint8Array,
|
|
): Promise<void> {
|
|
const path = chunkPath(scope, digestHex);
|
|
try {
|
|
const existing = await readFile(path);
|
|
if (
|
|
existing.byteLength !== bytes.byteLength ||
|
|
(await sha256Hex(dependencies.crypto, existing)) !== digestHex
|
|
) {
|
|
const { directory, fileName } = await resolveFileParent(
|
|
dependencies.root,
|
|
path,
|
|
false,
|
|
);
|
|
await directory.removeEntry(fileName);
|
|
} else {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
if (!isNotFound(error)) throw error;
|
|
}
|
|
|
|
const { directory, fileName } = await resolveFileParent(
|
|
dependencies.root,
|
|
path,
|
|
true,
|
|
);
|
|
const fileHandle = (await directory.getFileHandle(fileName, {
|
|
create: true,
|
|
})) as SyncCapableFileHandle;
|
|
if (
|
|
dependencies.dedicatedWorker &&
|
|
typeof fileHandle.createSyncAccessHandle === "function"
|
|
) {
|
|
try {
|
|
await writeWithSyncAccessHandle(fileHandle, bytes);
|
|
return;
|
|
} catch (error) {
|
|
if (
|
|
!dependencies.policy.allowAsyncWritableChunkFallback ||
|
|
!isSyncUnsupported(error)
|
|
) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
if (!dependencies.policy.allowAsyncWritableChunkFallback) {
|
|
throw new OpfsRuntimeFailure("UNSUPPORTED");
|
|
}
|
|
await writeWithAtomicWritable(fileHandle, bytes);
|
|
}
|
|
|
|
async function writeJsonAtomic(
|
|
path: readonly string[],
|
|
value: unknown,
|
|
): Promise<void> {
|
|
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
const { directory, fileName } = await resolveFileParent(
|
|
dependencies.root,
|
|
path,
|
|
true,
|
|
);
|
|
const handle = await directory.getFileHandle(fileName, { create: true });
|
|
await writeWithAtomicWritable(handle, bytes);
|
|
}
|
|
|
|
async function readJson(path: readonly string[]): Promise<unknown> {
|
|
const bytes = await readFile(path);
|
|
if (bytes.byteLength > MAX_MANIFEST_BYTES) {
|
|
throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
|
}
|
|
try {
|
|
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
} catch {
|
|
throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
|
}
|
|
}
|
|
|
|
async function readFile(path: readonly string[]): Promise<Uint8Array> {
|
|
const { directory, fileName } = await resolveFileParent(
|
|
dependencies.root,
|
|
path,
|
|
false,
|
|
);
|
|
const handle = await directory.getFileHandle(fileName);
|
|
const file = await handle.getFile();
|
|
return new Uint8Array(await file.arrayBuffer());
|
|
}
|
|
|
|
function assertWorkerAvailable(): void {
|
|
if (!requiredCapabilitiesAvailable) {
|
|
throw new OpfsRuntimeFailure("UNSUPPORTED");
|
|
}
|
|
}
|
|
}
|
|
|
|
export function installOpfsWorkerMessageHandler(
|
|
host: OpfsWorkerMessageHost,
|
|
runtime: OpfsWorkerRuntime,
|
|
): void {
|
|
host.addEventListener("message", (event) => {
|
|
void runtime.handleRequest(event.data).then((response) => {
|
|
postWorkerResponse(host, response);
|
|
});
|
|
});
|
|
}
|
|
|
|
function postWorkerResponse(
|
|
host: OpfsWorkerMessageHost,
|
|
response: OpfsWorkerResponse | null,
|
|
): void {
|
|
if (!response) return;
|
|
if (
|
|
response.ok &&
|
|
response.value instanceof ArrayBuffer
|
|
) {
|
|
host.postMessage(response, [response.value]);
|
|
return;
|
|
}
|
|
host.postMessage(response);
|
|
}
|
|
|
|
export function createWebLockLeaseManager(
|
|
lockManager: LockManagerLike,
|
|
lockName: string,
|
|
): OpfsMutationLeaseManager {
|
|
if (lockName.length === 0) {
|
|
throw new TypeError("OPFS mutation lock name is required.");
|
|
}
|
|
return Object.freeze({
|
|
async acquire(signal?: AbortSignal) {
|
|
if (signal?.aborted) {
|
|
throw new DOMException("The operation was aborted.", "AbortError");
|
|
}
|
|
let releaseHold: (() => void) | undefined;
|
|
let released = false;
|
|
const hold = new Promise<void>((resolve) => {
|
|
releaseHold = resolve;
|
|
});
|
|
let acquiredResolve: ((lease: OpfsMutationLease) => void) | undefined;
|
|
let acquiredReject: ((error: unknown) => void) | undefined;
|
|
const acquired = new Promise<OpfsMutationLease>((resolve, reject) => {
|
|
acquiredResolve = resolve;
|
|
acquiredReject = reject;
|
|
});
|
|
|
|
void lockManager
|
|
.request(lockName, { mode: "exclusive", signal }, async (lock) => {
|
|
if (!lock) throw new OpfsRuntimeFailure("BLOCKED", true);
|
|
const lease: OpfsMutationLease = Object.freeze({
|
|
release() {
|
|
if (released) return;
|
|
released = true;
|
|
releaseHold?.();
|
|
},
|
|
});
|
|
acquiredResolve?.(lease);
|
|
await hold;
|
|
})
|
|
.catch((error: unknown) => acquiredReject?.(error));
|
|
return await acquired;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Exported for a focused lifecycle test. Partial writes are retried and every
|
|
* acquired handle is closed, including failure paths.
|
|
*/
|
|
export async function writeWithSyncAccessHandle(
|
|
fileHandle: SyncCapableFileHandle,
|
|
bytes: Uint8Array,
|
|
): Promise<void> {
|
|
if (typeof fileHandle.createSyncAccessHandle !== "function") {
|
|
throw new OpfsRuntimeFailure("UNSUPPORTED");
|
|
}
|
|
let accessHandle: SyncAccessHandleLike | undefined;
|
|
try {
|
|
accessHandle = await fileHandle.createSyncAccessHandle();
|
|
accessHandle.truncate(0);
|
|
let offset = 0;
|
|
while (offset < bytes.byteLength) {
|
|
const written = accessHandle.write(bytes.subarray(offset), { at: offset });
|
|
if (
|
|
!Number.isSafeInteger(written) ||
|
|
written <= 0 ||
|
|
written > bytes.byteLength - offset
|
|
) {
|
|
throw new OpfsRuntimeFailure("NOT_READABLE", true);
|
|
}
|
|
offset += written;
|
|
}
|
|
accessHandle.truncate(bytes.byteLength);
|
|
accessHandle.flush();
|
|
} finally {
|
|
accessHandle?.close();
|
|
}
|
|
}
|
|
|
|
async function writeWithAtomicWritable(
|
|
fileHandle: FileSystemFileHandle,
|
|
bytes: Uint8Array,
|
|
): Promise<void> {
|
|
const writable = await fileHandle.createWritable({ keepExistingData: false });
|
|
let closed = false;
|
|
try {
|
|
const copy = Uint8Array.from(bytes);
|
|
await writable.write(copy);
|
|
await writable.close();
|
|
closed = true;
|
|
} finally {
|
|
if (!closed) {
|
|
try {
|
|
await writable.abort();
|
|
} catch {
|
|
// Preserve the original write error.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function resolveFileParent(
|
|
root: FileSystemDirectoryHandle,
|
|
path: readonly string[],
|
|
create: boolean,
|
|
): Promise<Readonly<{
|
|
directory: FileSystemDirectoryHandle;
|
|
fileName: string;
|
|
}>> {
|
|
if (path.length < 1 || path.some((segment) => !SAFE_PATH_SEGMENT.test(segment))) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
const fileName = path.at(-1)!;
|
|
const directory = await getDirectory(root, path.slice(0, -1), create);
|
|
return { directory, fileName };
|
|
}
|
|
|
|
async function getDirectory(
|
|
root: FileSystemDirectoryHandle,
|
|
path: readonly string[],
|
|
create: boolean,
|
|
): Promise<FileSystemDirectoryHandle> {
|
|
let current = root;
|
|
for (const segment of path) {
|
|
if (!SAFE_PATH_SEGMENT.test(segment)) {
|
|
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
|
}
|
|
current = await current.getDirectoryHandle(segment, { create });
|
|
}
|
|
return current;
|
|
}
|
|
|
|
async function removeEntryIfPresent(
|
|
directory: FileSystemDirectoryHandle,
|
|
name: string,
|
|
recursive: boolean,
|
|
): Promise<void> {
|
|
try {
|
|
await directory.removeEntry(name, { recursive });
|
|
} catch (error) {
|
|
if (!isNotFound(error)) throw error;
|
|
}
|
|
}
|
|
|
|
const CLEANUP_CLEANED: OpfsCleanupEffect = Object.freeze({ kind: "CLEANED" });
|
|
const CLEANUP_ALREADY_CLEAN: OpfsCleanupEffect = Object.freeze({
|
|
kind: "ALREADY_CLEAN",
|
|
});
|
|
|
|
const PHYSICAL_GENERATION_ID = /^[0-9a-f]{32}$/u;
|
|
const V1_GENERATION_SEGMENT = /^\d+$/u;
|
|
const V2_GENERATION_SEGMENT = /^g\d+-[0-9a-f]{32}$/u;
|
|
|
|
export function isPhysicalGenerationId(
|
|
value: unknown,
|
|
): value is OpfsPhysicalGenerationId {
|
|
return typeof value === "string" && PHYSICAL_GENERATION_ID.test(value);
|
|
}
|
|
|
|
/**
|
|
* STO-01. v1 wrote `objects/<prefix>/<id>/<generation>/`, which two
|
|
* transactions can legitimately share. v2 writes
|
|
* `objects/<prefix>/<id>/g<generation>-<token>/` so a late compensation can
|
|
* only ever delete its own transaction's directory. v1 segments stay readable
|
|
* through the rollback window.
|
|
*/
|
|
function generationSegmentFor(
|
|
generation: number,
|
|
physicalGenerationId: OpfsPhysicalGenerationId | undefined,
|
|
): string {
|
|
return physicalGenerationId === undefined
|
|
? String(generation)
|
|
: `g${generation}-${physicalGenerationId}`;
|
|
}
|
|
|
|
function preparedGenerationSegment(
|
|
preparedObject: OpfsPreparedObject,
|
|
): string {
|
|
return generationSegmentFor(
|
|
preparedObject.descriptor.generation,
|
|
preparedObject.physicalSchemaVersion === 2
|
|
? preparedObject.physicalGenerationId
|
|
: undefined,
|
|
);
|
|
}
|
|
|
|
function isGenerationSegment(name: string): boolean {
|
|
return V1_GENERATION_SEGMENT.test(name) || V2_GENERATION_SEGMENT.test(name);
|
|
}
|
|
|
|
function manifestPath(preparedObject: OpfsPreparedObject): readonly string[] {
|
|
const descriptor = preparedObject.descriptor;
|
|
return [
|
|
...scopeRootPath(descriptor.scope),
|
|
"objects",
|
|
descriptor.objectId.slice(0, 2),
|
|
descriptor.objectId,
|
|
preparedGenerationSegment(preparedObject),
|
|
"manifest.json",
|
|
];
|
|
}
|
|
|
|
function chunkPath(
|
|
scope: OpfsStorageScope,
|
|
digestHex: string,
|
|
): readonly string[] {
|
|
return [
|
|
...scopeRootPath(scope),
|
|
"chunks",
|
|
"sha256",
|
|
digestHex.slice(0, 2),
|
|
`${digestHex}.bin`,
|
|
];
|
|
}
|
|
|
|
function receiptPath(
|
|
scope: OpfsStorageScope,
|
|
transactionId: string,
|
|
): readonly string[] {
|
|
return [
|
|
...scopeRootPath(scope),
|
|
"staging",
|
|
transactionId,
|
|
"receipt.json",
|
|
];
|
|
}
|
|
|
|
function scopeRootPath(scope: OpfsStorageScope): readonly string[] {
|
|
return [
|
|
"authorities",
|
|
scope.authorityToken,
|
|
scope.namespaceToken,
|
|
scope.partitionToken,
|
|
];
|
|
}
|
|
|
|
function scopedTransactionKey(
|
|
scope: OpfsStorageScope,
|
|
transactionId: string,
|
|
): string {
|
|
return [
|
|
scope.authorityToken,
|
|
scope.namespaceToken,
|
|
scope.partitionToken,
|
|
transactionId,
|
|
].join("|");
|
|
}
|
|
|
|
function sameScope(
|
|
left: OpfsStorageScope,
|
|
right: OpfsStorageScope,
|
|
): boolean {
|
|
return (
|
|
left.namespace === right.namespace &&
|
|
left.authorityToken === right.authorityToken &&
|
|
left.namespaceToken === right.namespaceToken &&
|
|
left.partitionToken === right.partitionToken
|
|
);
|
|
}
|
|
|
|
function receiptBelongsToScope(
|
|
receipt: unknown,
|
|
scope: OpfsStorageScope,
|
|
): boolean {
|
|
if (!receipt || typeof receipt !== "object") return false;
|
|
const record = receipt as Record<string, unknown>;
|
|
if (
|
|
// v1 receipts stay readable through the rollback window.
|
|
(record.schemaVersion !== 1 && record.schemaVersion !== 2) ||
|
|
(record.phase !== "PREPARING" && record.phase !== "FILES_READY")
|
|
) {
|
|
return false;
|
|
}
|
|
if (record.phase === "PREPARING") {
|
|
return Boolean(
|
|
record.scope &&
|
|
typeof record.scope === "object" &&
|
|
isValidOpfsStorageScope(record.scope as OpfsStorageScope) &&
|
|
sameScope(record.scope as OpfsStorageScope, scope),
|
|
);
|
|
}
|
|
if (
|
|
!record.preparedObject ||
|
|
typeof record.preparedObject !== "object" ||
|
|
!("descriptor" in record.preparedObject) ||
|
|
!record.preparedObject.descriptor ||
|
|
typeof record.preparedObject.descriptor !== "object" ||
|
|
!("scope" in record.preparedObject.descriptor)
|
|
) {
|
|
return false;
|
|
}
|
|
const preparedScope = record.preparedObject.descriptor.scope;
|
|
return Boolean(
|
|
preparedScope &&
|
|
typeof preparedScope === "object" &&
|
|
isValidOpfsStorageScope(preparedScope as OpfsStorageScope) &&
|
|
sameScope(preparedScope as OpfsStorageScope, scope),
|
|
);
|
|
}
|
|
|
|
async function sha256Hex(
|
|
crypto: Crypto,
|
|
bytes: Uint8Array,
|
|
): Promise<string> {
|
|
const copy = Uint8Array.from(bytes);
|
|
const digest = await crypto.subtle.digest("SHA-256", copy);
|
|
return bytesToHex(new Uint8Array(digest));
|
|
}
|
|
|
|
async function treeDigestHex(
|
|
crypto: Crypto,
|
|
chunkSizeBytes: number,
|
|
byteLength: number,
|
|
chunks: readonly OpfsChunkReference[],
|
|
): Promise<string> {
|
|
const canonical = [
|
|
"sha-256-tree-v1",
|
|
`chunk-size:${chunkSizeBytes}`,
|
|
`byte-length:${byteLength}`,
|
|
...chunks.map(
|
|
(chunk) =>
|
|
`${chunk.sequence}:${chunk.byteLength}:${chunk.digestHex}`,
|
|
),
|
|
"",
|
|
].join("\n");
|
|
return await sha256Hex(crypto, new TextEncoder().encode(canonical));
|
|
}
|
|
|
|
function bytesToHex(bytes: Uint8Array): string {
|
|
let hex = "";
|
|
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
return hex;
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (
|
|
value === null ||
|
|
typeof value === "string" ||
|
|
typeof value === "boolean"
|
|
) {
|
|
return JSON.stringify(value);
|
|
}
|
|
if (typeof value === "number") {
|
|
if (!Number.isFinite(value)) throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
|
return JSON.stringify(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(stableJson).join(",")}]`;
|
|
}
|
|
if (typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
return `{${Object.keys(record)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
|
.join(",")}}`;
|
|
}
|
|
throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
|
}
|
|
|
|
/**
|
|
* STO-01 expand phase. v1 prepared objects stay readable through the rollback
|
|
* window; v2 additionally carries a transaction-unique physical fencing token.
|
|
*/
|
|
function isSupportedPhysicalSchema(value: object): boolean {
|
|
const record = value as Record<string, unknown>;
|
|
if (record.physicalSchemaVersion === 1) return true;
|
|
return (
|
|
record.physicalSchemaVersion === 2 &&
|
|
typeof record.physicalGenerationId === "string" &&
|
|
/^[0-9a-f]{32}$/u.test(record.physicalGenerationId)
|
|
);
|
|
}
|
|
|
|
function isPreparedObjectSafe(
|
|
value: unknown,
|
|
policy: OpfsRuntimePolicy,
|
|
): value is OpfsPreparedObject {
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
!("physicalSchemaVersion" in value) ||
|
|
!isSupportedPhysicalSchema(value) ||
|
|
!("descriptor" in value) ||
|
|
!value.descriptor ||
|
|
typeof value.descriptor !== "object" ||
|
|
!("chunks" in value) ||
|
|
!Array.isArray(value.chunks)
|
|
) {
|
|
return false;
|
|
}
|
|
const descriptor = value.descriptor as Record<string, unknown>;
|
|
const integrity =
|
|
descriptor.integrity && typeof descriptor.integrity === "object"
|
|
? (descriptor.integrity as Record<string, unknown>)
|
|
: null;
|
|
if (
|
|
typeof descriptor.objectId !== "string" ||
|
|
!policy.isObjectIdAllowed(descriptor.objectId) ||
|
|
!("scope" in descriptor) ||
|
|
!descriptor.scope ||
|
|
typeof descriptor.scope !== "object" ||
|
|
!isValidOpfsStorageScope(
|
|
descriptor.scope as OpfsPreparedObject["descriptor"]["scope"],
|
|
) ||
|
|
(descriptor.scope as OpfsPreparedObject["descriptor"]["scope"])
|
|
.namespace !==
|
|
(descriptor.storagePolicy as
|
|
| OpfsPreparedObject["descriptor"]["storagePolicy"]
|
|
| undefined)?.namespace ||
|
|
typeof descriptor.generation !== "number" ||
|
|
!Number.isSafeInteger(descriptor.generation) ||
|
|
descriptor.generation < 1 ||
|
|
typeof descriptor.byteLength !== "number" ||
|
|
!Number.isSafeInteger(descriptor.byteLength) ||
|
|
descriptor.byteLength < 0 ||
|
|
descriptor.byteLength > policy.maxObjectBytes ||
|
|
typeof descriptor.mediaType !== "string" ||
|
|
!policy.isMediaTypeAllowed(descriptor.mediaType) ||
|
|
typeof descriptor.createdAtEpochMs !== "number" ||
|
|
!Number.isSafeInteger(descriptor.createdAtEpochMs) ||
|
|
descriptor.createdAtEpochMs < 0 ||
|
|
!integrity ||
|
|
integrity.algorithm !== "SHA-256-TREE-V1" ||
|
|
typeof integrity.rootDigestHex !== "string" ||
|
|
!SHA256_HEX.test(integrity.rootDigestHex) ||
|
|
integrity.chunkSizeBytes !== policy.chunkSizeBytes ||
|
|
!("storagePolicy" in descriptor) ||
|
|
!descriptor.storagePolicy ||
|
|
typeof descriptor.storagePolicy !== "object" ||
|
|
value.chunks.length > policy.maxChunkCount
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
assertValidStoragePolicy(
|
|
descriptor.storagePolicy as OpfsPreparedObject["descriptor"]["storagePolicy"],
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
let total = 0;
|
|
for (let index = 0; index < value.chunks.length; index += 1) {
|
|
const chunk = value.chunks[index] as unknown;
|
|
if (
|
|
!chunk ||
|
|
typeof chunk !== "object" ||
|
|
!("sequence" in chunk) ||
|
|
chunk.sequence !== index ||
|
|
!("byteLength" in chunk) ||
|
|
typeof chunk.byteLength !== "number" ||
|
|
!Number.isSafeInteger(chunk.byteLength) ||
|
|
chunk.byteLength < 1 ||
|
|
chunk.byteLength > policy.chunkSizeBytes ||
|
|
!("digestHex" in chunk) ||
|
|
typeof chunk.digestHex !== "string" ||
|
|
!SHA256_HEX.test(chunk.digestHex)
|
|
) {
|
|
return false;
|
|
}
|
|
if (index < value.chunks.length - 1 && chunk.byteLength !== policy.chunkSizeBytes) {
|
|
return false;
|
|
}
|
|
total += chunk.byteLength;
|
|
}
|
|
return (
|
|
total === descriptor.byteLength &&
|
|
value.chunks.length ===
|
|
Math.ceil(descriptor.byteLength / policy.chunkSizeBytes)
|
|
);
|
|
}
|
|
|
|
function success(
|
|
requestId: string,
|
|
kind: OpfsWorkerRequest["kind"],
|
|
value?: OpfsWorkerResponse extends infer _Response
|
|
?
|
|
| OpfsCapabilities
|
|
| OpfsCleanupEffect
|
|
| OpfsPreparedObject
|
|
| ArrayBuffer
|
|
| boolean
|
|
| OpfsOrphanCandidateBatch
|
|
| OpfsOrphanDeleteResult
|
|
: never,
|
|
): OpfsWorkerResponse {
|
|
return value === undefined
|
|
? {
|
|
requestId,
|
|
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
|
kind,
|
|
ok: true,
|
|
}
|
|
: {
|
|
requestId,
|
|
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
|
kind,
|
|
ok: true,
|
|
value,
|
|
};
|
|
}
|
|
|
|
function failure(
|
|
requestId: string,
|
|
workerFailure: OpfsWorkerFailure,
|
|
kind: OpfsWorkerRequest["kind"] = "CAPABILITIES",
|
|
): OpfsWorkerResponse {
|
|
return {
|
|
requestId,
|
|
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
|
kind,
|
|
ok: false,
|
|
failure: workerFailure,
|
|
};
|
|
}
|
|
|
|
function mapRuntimeFailure(error: unknown): OpfsWorkerFailure {
|
|
if (error instanceof OpfsRuntimeFailure) {
|
|
return { code: error.code, retryable: error.retryable };
|
|
}
|
|
if (error instanceof DOMException) {
|
|
if (error.name === "QuotaExceededError") {
|
|
return { code: "QUOTA_EXCEEDED", retryable: true };
|
|
}
|
|
if (error.name === "NotFoundError") {
|
|
return { code: "NOT_FOUND", retryable: false };
|
|
}
|
|
if (
|
|
error.name === "NoModificationAllowedError" ||
|
|
error.name === "InvalidStateError"
|
|
) {
|
|
return { code: "BLOCKED", retryable: true };
|
|
}
|
|
if (error.name === "NotAllowedError" || error.name === "SecurityError") {
|
|
return { code: "PERMISSION_DENIED", retryable: false };
|
|
}
|
|
if (error.name === "AbortError") {
|
|
return { code: "ABORTED", retryable: false };
|
|
}
|
|
if (error.name === "NotSupportedError") {
|
|
return { code: "UNSUPPORTED", retryable: false };
|
|
}
|
|
}
|
|
return { code: "UNAVAILABLE", retryable: true };
|
|
}
|
|
|
|
function isSyncUnsupported(error: unknown): boolean {
|
|
return (
|
|
error instanceof TypeError ||
|
|
(error instanceof DOMException && error.name === "NotSupportedError")
|
|
);
|
|
}
|
|
|
|
function isNotFound(error: unknown): boolean {
|
|
return error instanceof DOMException && error.name === "NotFoundError";
|
|
}
|
|
|
|
function hasRequestId(
|
|
value: unknown,
|
|
): value is Readonly<{ requestId: string }> {
|
|
return Boolean(
|
|
value &&
|
|
typeof value === "object" &&
|
|
"requestId" in value &&
|
|
typeof value.requestId === "string" &&
|
|
value.requestId.length > 0 &&
|
|
value.requestId.length <= 128,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* NS-05. Reads a request envelope's correlation exactly once, through its own
|
|
* data descriptors, so a reply can name the request it answers even when the
|
|
* runtime that would have handled it never came up. An envelope whose kind
|
|
* cannot be read stays a protocol-level failure rather than borrowing an
|
|
* unrelated kind.
|
|
*/
|
|
function requestCorrelation(
|
|
value: Readonly<{ requestId: string }>,
|
|
): Readonly<{ requestId: string; kind: OpfsWorkerRequest["kind"] }> {
|
|
let kind: unknown;
|
|
try {
|
|
kind = Object.getOwnPropertyDescriptor(value, "kind")?.value;
|
|
} catch {
|
|
kind = undefined;
|
|
}
|
|
return {
|
|
requestId: value.requestId,
|
|
kind:
|
|
typeof kind === "string" && WORKER_REQUEST_KINDS.has(kind)
|
|
? (kind as OpfsWorkerRequest["kind"])
|
|
: "CAPABILITIES",
|
|
};
|
|
}
|
|
|
|
function isWorkerRequest(value: unknown): value is OpfsWorkerRequest {
|
|
return Boolean(
|
|
hasRequestId(value) &&
|
|
"kind" in value &&
|
|
typeof value.kind === "string" &&
|
|
WORKER_REQUEST_KINDS.has(value.kind) &&
|
|
// STO-07. A page from another release must not be served.
|
|
"protocolVersion" in value &&
|
|
value.protocolVersion === OPFS_WORKER_PROTOCOL_VERSION,
|
|
);
|
|
}
|
|
|
|
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
const SAFE_TRANSACTION_ID = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
const SAFE_PATH_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
|
|
const SHA256_HEX = /^[a-f0-9]{64}$/u;
|
|
const WORKER_REQUEST_KINDS = new Set<string>([
|
|
"CAPABILITIES",
|
|
"BEGIN_PUT",
|
|
"APPEND_CHUNK",
|
|
"FINISH_PUT",
|
|
"ABORT_PUT",
|
|
"VERIFY_OBJECT",
|
|
"READ_CHUNK",
|
|
"REMOVE_OBJECT",
|
|
"CLEANUP_TRANSACTION",
|
|
"FINALIZE_PUT",
|
|
"LIST_ORPHAN_CANDIDATES",
|
|
"DELETE_ORPHAN_CHUNK",
|
|
]);
|
|
|
|
function extractReceiptDigests(
|
|
receipt: unknown,
|
|
): readonly string[] | null {
|
|
if (!receipt || typeof receipt !== "object") return null;
|
|
const record = receipt as Record<string, unknown>;
|
|
let chunks: unknown;
|
|
if (Array.isArray(record.chunks)) {
|
|
chunks = record.chunks;
|
|
} else if (
|
|
record.preparedObject &&
|
|
typeof record.preparedObject === "object" &&
|
|
"chunks" in record.preparedObject
|
|
) {
|
|
chunks = record.preparedObject.chunks;
|
|
} else {
|
|
return null;
|
|
}
|
|
if (!Array.isArray(chunks)) return null;
|
|
const digests: string[] = [];
|
|
for (const chunk of chunks) {
|
|
if (
|
|
!chunk ||
|
|
typeof chunk !== "object" ||
|
|
!("digestHex" in chunk) ||
|
|
typeof chunk.digestHex !== "string" ||
|
|
!SHA256_HEX.test(chunk.digestHex)
|
|
) {
|
|
return null;
|
|
}
|
|
digests.push(chunk.digestHex);
|
|
}
|
|
return digests;
|
|
}
|