chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -972,6 +972,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
prepared: readonly PreparedRecord<WireValue>[],
|
||||
scan: ScanBatch,
|
||||
budgetExhausted: boolean,
|
||||
deadline: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<
|
||||
BrowserDataResult<IndexedDbMaintenanceBatchReceipt>
|
||||
@@ -1048,6 +1049,22 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
finishWithCheckpoint();
|
||||
return;
|
||||
}
|
||||
// STO-06. The commit chain runs entirely inside IndexedDB callbacks,
|
||||
// so without this check up to `maxRows` records keep executing past
|
||||
// the caller's invocation deadline. The budget is only ever checked
|
||||
// before a record's first write, so a started record still finishes
|
||||
// atomically and the checkpoint stays exactly at the last safe key.
|
||||
const currentTime = clock();
|
||||
if (!currentTime.ok) {
|
||||
// A broken clock aborts rather than committing an unbounded batch.
|
||||
context.fail(currentTime);
|
||||
return;
|
||||
}
|
||||
if (currentTime.value >= deadline) {
|
||||
budgetExhausted = true;
|
||||
finishWithCheckpoint();
|
||||
return;
|
||||
}
|
||||
let request: IDBRequest<unknown>;
|
||||
try {
|
||||
request = records.get(preparedRecord.source.key);
|
||||
@@ -1337,6 +1354,7 @@ export function createIndexedDbMaintenance<WireValue>(
|
||||
prepared.value.records,
|
||||
scan.value,
|
||||
prepared.value.budgetExhausted,
|
||||
deadline,
|
||||
input.signal,
|
||||
);
|
||||
return observeResult(
|
||||
|
||||
@@ -1650,12 +1650,26 @@ function isChunkReference(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 isPreparedObject(value: unknown): value is OpfsPreparedObject {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("physicalSchemaVersion" in value) ||
|
||||
value.physicalSchemaVersion !== 1 ||
|
||||
!isSupportedPhysicalSchema(value) ||
|
||||
!("descriptor" in value) ||
|
||||
!value.descriptor ||
|
||||
typeof value.descriptor !== "object" ||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
OpfsReconciliationReport,
|
||||
OpfsStorageScope,
|
||||
PutDurableObjectRequest,
|
||||
OpfsPhysicalGenerationId,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import {
|
||||
type BrowserDataFailure,
|
||||
@@ -89,6 +90,12 @@ export type OpfsByteStoreDependencies = Readonly<{
|
||||
storagePolicy: BrowserStoragePolicy;
|
||||
policy?: Partial<OpfsRuntimePolicy>;
|
||||
createTransactionId?: () => string;
|
||||
createPhysicalGenerationId?: () => OpfsPhysicalGenerationId;
|
||||
/**
|
||||
* Composition-owned bounded signal for compensating cleanup. It is
|
||||
* deliberately separate from any caller signal.
|
||||
*/
|
||||
compensationSignal?: AbortSignal;
|
||||
now?: () => number;
|
||||
observer?: OpfsSafeObserver;
|
||||
/**
|
||||
@@ -130,6 +137,20 @@ export function createOpfsByteStoreAdapter(
|
||||
const createTransactionId =
|
||||
dependencies.createTransactionId ??
|
||||
(() => globalThis.crypto.randomUUID());
|
||||
const createPhysicalGenerationId =
|
||||
dependencies.createPhysicalGenerationId ??
|
||||
(() => {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
let hex = "";
|
||||
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
||||
return hex as OpfsPhysicalGenerationId;
|
||||
});
|
||||
/**
|
||||
* STO-01. Compensation must not inherit the caller's already aborted signal;
|
||||
* a cleanup that never starts cannot justify releasing the journal row.
|
||||
*/
|
||||
const compensationSignal = dependencies.compensationSignal;
|
||||
const now = dependencies.now ?? Date.now;
|
||||
|
||||
const objects: DurableObjectStorePort = Object.freeze({
|
||||
@@ -182,6 +203,10 @@ export function createOpfsByteStoreAdapter(
|
||||
}
|
||||
const targetGeneration = (current?.descriptor.generation ?? 0) + 1;
|
||||
const transactionId = createTransactionId();
|
||||
// STO-01. The logical generation is reused across transactions; this
|
||||
// token makes the physical target unique so a late compensation can never
|
||||
// delete a newer transaction's directory.
|
||||
const physicalGenerationId = createPhysicalGenerationId();
|
||||
notifyProgress(request, "PREPARING", 0);
|
||||
const begun = await dependencies.journal.begin({
|
||||
transactionId,
|
||||
@@ -214,13 +239,24 @@ export function createOpfsByteStoreAdapter(
|
||||
});
|
||||
const prepared = await dependencies.worker.preparePut({
|
||||
transactionId,
|
||||
physicalGenerationId,
|
||||
descriptor,
|
||||
source: request.source,
|
||||
signal: request.signal,
|
||||
onProgress: request.onProgress,
|
||||
});
|
||||
if (!prepared.ok) {
|
||||
await rollbackBestEffort(begun.value, request.signal);
|
||||
const compensated = await compensatePreparedPut(
|
||||
begun.value,
|
||||
physicalGenerationId,
|
||||
);
|
||||
if (!compensated.ok) {
|
||||
return observeFailure(
|
||||
compensated,
|
||||
dependencies.observer,
|
||||
request.source.byteLength!,
|
||||
);
|
||||
}
|
||||
return observeFailure(
|
||||
prepared,
|
||||
dependencies.observer,
|
||||
@@ -234,7 +270,17 @@ export function createOpfsByteStoreAdapter(
|
||||
prepared.value,
|
||||
);
|
||||
if (!filesReady.ok) {
|
||||
await rollbackBestEffort(begun.value, request.signal);
|
||||
const compensated = await compensatePreparedPut(
|
||||
begun.value,
|
||||
physicalGenerationId,
|
||||
);
|
||||
if (!compensated.ok) {
|
||||
return observeFailure(
|
||||
compensated,
|
||||
dependencies.observer,
|
||||
request.source.byteLength!,
|
||||
);
|
||||
}
|
||||
return observeFailure(
|
||||
rebaseFailure(filesReady.error, "OBJECT_WRITE"),
|
||||
dependencies.observer,
|
||||
@@ -266,12 +312,45 @@ export function createOpfsByteStoreAdapter(
|
||||
prepared.value,
|
||||
request.signal,
|
||||
);
|
||||
if (finalized.ok) {
|
||||
await dependencies.journal.complete(
|
||||
transactionId,
|
||||
begun.value.fencingToken,
|
||||
if (!finalized.ok) {
|
||||
// STO-RR-01. The commit fence already passed, so the payload is durable
|
||||
// and the journal keeps its COMMITTED record for reconciliation to
|
||||
// settle. What did not happen is finalization: the previous generation
|
||||
// and the staging directory are still present. Reporting a plain
|
||||
// success here would claim a settled state nobody observed, so the
|
||||
// worker's own failure is surfaced and the record is left recoverable.
|
||||
return observeFailure(
|
||||
rebaseFailure(finalized.error, "OBJECT_WRITE"),
|
||||
dependencies.observer,
|
||||
request.source.byteLength!,
|
||||
);
|
||||
}
|
||||
const completed = await dependencies.journal.complete(
|
||||
transactionId,
|
||||
begun.value.fencingToken,
|
||||
);
|
||||
if (!completed.ok) {
|
||||
// NS-04. The payload is durable and finalized, so nothing is rolled
|
||||
// back and the `COMMITTED` row stays the reconciler's authority. What
|
||||
// did not happen is settling the transaction, and reporting a plain
|
||||
// success for it claimed a state nobody observed while the reconcile
|
||||
// backlog and its quota pressure grew unseen.
|
||||
observeOpfsSafely(dependencies.observer, {
|
||||
operation: "OBJECT_WRITE",
|
||||
outcome: "DEGRADED",
|
||||
failureCode: completed.error.code,
|
||||
byteBucket: byteBucket(prepared.value.descriptor.byteLength),
|
||||
});
|
||||
return {
|
||||
ok: false as const,
|
||||
error: Object.freeze({
|
||||
...completed.error,
|
||||
operation: "OBJECT_WRITE" as const,
|
||||
retryable: true,
|
||||
recovery: "RECONCILE" as const,
|
||||
}),
|
||||
};
|
||||
}
|
||||
observeOpfsSafely(dependencies.observer, {
|
||||
operation: "OBJECT_WRITE",
|
||||
outcome: "SUCCEEDED",
|
||||
@@ -438,17 +517,27 @@ export function createOpfsByteStoreAdapter(
|
||||
request.expectedGeneration,
|
||||
request.signal,
|
||||
);
|
||||
if (removed.ok) {
|
||||
await dependencies.journal.complete(
|
||||
transactionId,
|
||||
begun.value.fencingToken,
|
||||
);
|
||||
}
|
||||
const completed = removed.ok
|
||||
? await dependencies.journal.complete(
|
||||
transactionId,
|
||||
begun.value.fencingToken,
|
||||
)
|
||||
: null;
|
||||
// Logical deletion is already committed. Physical cleanup is retryable
|
||||
// maintenance and must not make the caller repeat a non-idempotent delete.
|
||||
// NS-04. It is still not a settled state: an unfinished physical removal
|
||||
// or an unsettled journal row is maintenance debt the reconciler owns, so
|
||||
// it is observed as such instead of as a clean success.
|
||||
const settled = removed.ok && completed !== null && completed.ok;
|
||||
const debtCode = removed.ok
|
||||
? completed !== null && !completed.ok
|
||||
? completed.error.code
|
||||
: undefined
|
||||
: removed.error.code;
|
||||
observeOpfsSafely(dependencies.observer, {
|
||||
operation: "OBJECT_DELETE",
|
||||
outcome: "SUCCEEDED",
|
||||
outcome: settled ? "SUCCEEDED" : "DEGRADED",
|
||||
...(debtCode ? { failureCode: debtCode } : {}),
|
||||
});
|
||||
return browserDataSuccess(undefined);
|
||||
},
|
||||
@@ -830,19 +919,38 @@ export function createOpfsByteStoreAdapter(
|
||||
|
||||
return Object.freeze({ objects, maintenance });
|
||||
|
||||
async function rollbackBestEffort(
|
||||
/**
|
||||
* STO-01. The compensating half of the put saga.
|
||||
*
|
||||
* The journal row and its budget reservation are the only durable evidence
|
||||
* that a physical staging generation may still exist, so they are released
|
||||
* exactly when the physical effect is confirmed `CLEANED` or
|
||||
* `ALREADY_CLEAN`. A timeout, crash, malformed response or `EFFECT_UNKNOWN`
|
||||
* keeps `PREPARING`/`FILES_READY` in place and asks for reconciliation.
|
||||
*/
|
||||
async function compensatePreparedPut(
|
||||
transaction: OpfsJournalTransaction,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
await dependencies.worker.cleanupTransaction(
|
||||
transaction.scope,
|
||||
transaction.transactionId,
|
||||
signal,
|
||||
);
|
||||
await dependencies.journal.rollback(
|
||||
physicalGenerationId: OpfsPhysicalGenerationId,
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
const cleanup = await dependencies.worker.abortPreparedPut({
|
||||
scope: transaction.scope,
|
||||
transactionId: transaction.transactionId,
|
||||
physicalGenerationId,
|
||||
...(compensationSignal ? { signal: compensationSignal } : {}),
|
||||
});
|
||||
if (!cleanup.ok || cleanup.value.kind === "EFFECT_UNKNOWN") {
|
||||
return browserDataFailure("CONFLICT", "OBJECT_RECONCILE", {
|
||||
recovery: "RETRY",
|
||||
});
|
||||
}
|
||||
const rolledBack = await dependencies.journal.rollback(
|
||||
transaction.transactionId,
|
||||
transaction.fencingToken,
|
||||
);
|
||||
if (!rolledBack.ok) {
|
||||
return rebaseFailure(rolledBack.error, "OBJECT_RECONCILE");
|
||||
}
|
||||
return browserDataSuccess(undefined);
|
||||
}
|
||||
|
||||
async function reconcileTransaction(
|
||||
@@ -860,6 +968,11 @@ export function createOpfsByteStoreAdapter(
|
||||
signal,
|
||||
);
|
||||
if (!cleaned.ok) return cleaned;
|
||||
if (cleaned.value.kind === "EFFECT_UNKNOWN") {
|
||||
return browserDataFailure("CONFLICT", "OBJECT_RECONCILE", {
|
||||
recovery: "RETRY",
|
||||
});
|
||||
}
|
||||
const rolledBack = await dependencies.journal.rollback(
|
||||
transaction.transactionId,
|
||||
transaction.fencingToken,
|
||||
@@ -1292,6 +1405,7 @@ function snapshotOpfsWorker(
|
||||
openObject,
|
||||
removeObject,
|
||||
cleanupTransaction,
|
||||
abortPreparedPut,
|
||||
finalizePut,
|
||||
listOrphanCandidates,
|
||||
deleteOrphanChunk,
|
||||
@@ -1305,6 +1419,7 @@ function snapshotOpfsWorker(
|
||||
openObject,
|
||||
removeObject,
|
||||
cleanupTransaction,
|
||||
abortPreparedPut,
|
||||
finalizePut,
|
||||
listOrphanCandidates,
|
||||
deleteOrphanChunk,
|
||||
@@ -1320,6 +1435,7 @@ function snapshotOpfsWorker(
|
||||
openObject: openObject.bind(source),
|
||||
removeObject: removeObject.bind(source),
|
||||
cleanupTransaction: cleanupTransaction.bind(source),
|
||||
abortPreparedPut: abortPreparedPut.bind(source),
|
||||
finalizePut: finalizePut.bind(source),
|
||||
listOrphanCandidates: listOrphanCandidates.bind(source),
|
||||
deleteOrphanChunk: deleteOrphanChunk.bind(source),
|
||||
|
||||
@@ -26,7 +26,11 @@ export type OpfsRuntimePolicy = Readonly<{
|
||||
|
||||
export type OpfsSafeObservation = Readonly<{
|
||||
operation: BrowserDataOperation;
|
||||
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
|
||||
/**
|
||||
* NS-04. `DEGRADED` is a committed effect whose bookkeeping is unsettled:
|
||||
* the payload is durable but a reconciler still owns the transaction.
|
||||
*/
|
||||
outcome: "STARTED" | "SUCCEEDED" | "FAILED" | "DEGRADED";
|
||||
failureCode?: BrowserDataFailureCode;
|
||||
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB";
|
||||
transactionBucket?: "0" | "1_10" | "11_100" | "GT_100";
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import type {
|
||||
OpfsCapabilities,
|
||||
OpfsCleanupEffect,
|
||||
OpfsPreparedObject,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
BrowserDataFailureCode,
|
||||
BrowserDataOperation,
|
||||
BrowserDataResult,
|
||||
ByteSource,
|
||||
import {
|
||||
isBrowserDataFailureCode,
|
||||
type BrowserDataFailureCode,
|
||||
type BrowserDataOperation,
|
||||
type BrowserDataResult,
|
||||
type 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 {
|
||||
OPFS_WORKER_PROTOCOL_VERSION,
|
||||
} from "./opfs-worker-protocol.ts";
|
||||
import type {
|
||||
AbortPreparedPutRequest,
|
||||
OpfsWorkerGateway,
|
||||
OpfsOrphanCandidateBatch,
|
||||
OpfsOrphanDeleteResult,
|
||||
@@ -49,6 +55,7 @@ export type OwnedOpfsWorkerClient = Readonly<{
|
||||
}>;
|
||||
|
||||
type PendingRequest = Readonly<{
|
||||
expectedKind: OpfsWorkerRequest["kind"];
|
||||
resolve: (response: OpfsWorkerResponse) => void;
|
||||
reject: (error: OpfsRpcError) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
@@ -74,24 +81,49 @@ export function createOpfsWorkerGateway(
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
let disposed = false;
|
||||
|
||||
const rejectAllPending = (): void => {
|
||||
const rejectAllPending = (
|
||||
code: "UNAVAILABLE" | "UNSUPPORTED" = "UNAVAILABLE",
|
||||
): void => {
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.removeAbortListener();
|
||||
request.reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
request.reject(new OpfsRpcError(code));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent<unknown>): void => {
|
||||
if (disposed) return;
|
||||
if (!isWorkerResponse(event.data)) return;
|
||||
const request = pending.get(event.data.requestId);
|
||||
const correlation = readCorrelation(event.data);
|
||||
if (correlation === IGNORE_MESSAGE) return;
|
||||
if (correlation === UNREADABLE_CORRELATION) {
|
||||
// NS-06. A reply whose correlation cannot even be read is a protocol
|
||||
// breach on the only channel this client has. Ignoring it left every
|
||||
// in-flight request to expire on the RPC timer, so the whole channel
|
||||
// fails closed promptly instead.
|
||||
rejectAllPending("UNSUPPORTED");
|
||||
return;
|
||||
}
|
||||
const request = pending.get(correlation);
|
||||
if (!request) return;
|
||||
pending.delete(event.data.requestId);
|
||||
// STO-07 / STO-RR-03. A reply is decoded, never adopted. A different
|
||||
// operation, an unknown kind, an unknown failure code, an inherited or
|
||||
// extra field and a hostile accessor are all protocol breaches, and each
|
||||
// closes the request rather than leaving it to time out.
|
||||
// UNSUPPORTED is the closed-taxonomy code for "this runtime cannot serve
|
||||
// this"; no new failure code is invented.
|
||||
// NS-06. The decode happens before the pending row and its timer are
|
||||
// released: releasing them first meant a trap that threw inside the decoder
|
||||
// left the public promise pending with nothing left to time it out.
|
||||
const decoded = decodeWorkerResponse(event.data, request.expectedKind);
|
||||
pending.delete(correlation);
|
||||
clearTimeout(request.timeout);
|
||||
request.removeAbortListener();
|
||||
request.resolve(event.data);
|
||||
if (decoded === null) {
|
||||
request.reject(new OpfsRpcError("UNSUPPORTED"));
|
||||
return;
|
||||
}
|
||||
request.resolve(decoded);
|
||||
};
|
||||
const onWorkerFailure = (): void => {
|
||||
if (disposed) return;
|
||||
@@ -119,7 +151,11 @@ export function createOpfsWorkerGateway(
|
||||
) {
|
||||
throw new OpfsRpcError("UNAVAILABLE");
|
||||
}
|
||||
const message = { ...request, requestId } as OpfsWorkerRequest;
|
||||
const message = {
|
||||
...request,
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
} as OpfsWorkerRequest;
|
||||
|
||||
return await new Promise<OpfsWorkerResponse>((resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
@@ -139,6 +175,9 @@ export function createOpfsWorkerGateway(
|
||||
reject(new OpfsRpcError("UNAVAILABLE"));
|
||||
}, dependencies.policy.rpcTimeoutMs);
|
||||
pending.set(requestId, {
|
||||
// STO-07. The expected kind is stored so a reply for a different
|
||||
// operation can never satisfy this request.
|
||||
expectedKind: request.kind,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
@@ -187,17 +226,6 @@ export function createOpfsWorkerGateway(
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -215,6 +243,7 @@ export function createOpfsWorkerGateway(
|
||||
{
|
||||
kind: "BEGIN_PUT",
|
||||
transactionId: request.transactionId,
|
||||
physicalGenerationId: request.physicalGenerationId,
|
||||
scope: request.descriptor.scope,
|
||||
objectId: request.descriptor.objectId,
|
||||
generation: request.descriptor.generation,
|
||||
@@ -227,10 +256,8 @@ export function createOpfsWorkerGateway(
|
||||
request.signal,
|
||||
);
|
||||
if (!begin.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
// STO-01. Compensation belongs to the coordinator: it owns the journal
|
||||
// row this cleanup would otherwise invalidate.
|
||||
return begin;
|
||||
}
|
||||
|
||||
@@ -257,22 +284,12 @@ export function createOpfsWorkerGateway(
|
||||
request.signal,
|
||||
[chunk],
|
||||
);
|
||||
if (!append.ok) {
|
||||
await abortAndCleanup(
|
||||
request.descriptor.scope,
|
||||
request.transactionId,
|
||||
);
|
||||
return append;
|
||||
}
|
||||
if (!append.ok) 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",
|
||||
});
|
||||
@@ -289,18 +306,8 @@ export function createOpfsWorkerGateway(
|
||||
[],
|
||||
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",
|
||||
@@ -387,10 +394,32 @@ export function createOpfsWorkerGateway(
|
||||
transactionId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return await invoke<void>(
|
||||
return await invoke(
|
||||
"OBJECT_RECONCILE",
|
||||
{ kind: "CLEANUP_TRANSACTION", scope, transactionId },
|
||||
signal,
|
||||
[],
|
||||
parseCleanupEffect,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* STO-01. The coordinator's single compensation entry point. An RPC that
|
||||
* times out or fails leaves the physical effect unknown, which is never a
|
||||
* success and must not release journal or budget state.
|
||||
*/
|
||||
async abortPreparedPut(request: AbortPreparedPutRequest) {
|
||||
return await invoke(
|
||||
"OBJECT_RECONCILE",
|
||||
{
|
||||
kind: "ABORT_PUT",
|
||||
scope: request.scope,
|
||||
transactionId: request.transactionId,
|
||||
physicalGenerationId: request.physicalGenerationId,
|
||||
},
|
||||
request.signal,
|
||||
[],
|
||||
parseCleanupEffect,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -610,12 +639,34 @@ function parseCapabilities(value: unknown): OpfsCapabilities | null {
|
||||
return value as OpfsCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 parseCleanupEffect(value: unknown): OpfsCleanupEffect | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const kind = (value as Record<string, unknown>).kind;
|
||||
return kind === "CLEANED" || kind === "ALREADY_CLEAN"
|
||||
? Object.freeze({ kind })
|
||||
: null;
|
||||
}
|
||||
|
||||
function parsePreparedObject(value: unknown): OpfsPreparedObject | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("physicalSchemaVersion" in value) ||
|
||||
value.physicalSchemaVersion !== 1 ||
|
||||
!isSupportedPhysicalSchema(value) ||
|
||||
!("descriptor" in value) ||
|
||||
!("chunks" in value) ||
|
||||
!Array.isArray(value.chunks)
|
||||
@@ -663,13 +714,148 @@ function parseOrphanDeleteResult(
|
||||
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",
|
||||
);
|
||||
/**
|
||||
* STO-07 / STO-RR-03. A response is admitted only when every field survives an
|
||||
* exact own-data decode: the negotiated protocol version, the exact request
|
||||
* kind this call is waiting for and, on failure, a code inside the closed
|
||||
* `BrowserDataFailure` taxonomy with a boolean `retryable`.
|
||||
*
|
||||
* The decoder returns a fresh frozen value, so a worker that mutates its own
|
||||
* message object after posting it cannot change what the caller already read.
|
||||
*/
|
||||
const WORKER_RESPONSE_KEYS: ReadonlySet<string> = new Set([
|
||||
"requestId",
|
||||
"protocolVersion",
|
||||
"kind",
|
||||
"ok",
|
||||
"value",
|
||||
"failure",
|
||||
]);
|
||||
|
||||
const WORKER_FAILURE_KEYS: ReadonlySet<string> = new Set(["code", "retryable"]);
|
||||
|
||||
/** Reads one own data property, treating an accessor or a trap as absent. */
|
||||
function ownField(source: unknown, key: string): unknown {
|
||||
if (source === null || typeof source !== "object") return undefined;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) return undefined;
|
||||
return descriptor.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function ownStringField(source: unknown, key: string): string | null {
|
||||
const value = ownField(source, key);
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/** Not a reply at all: nothing on this channel is waiting for it. */
|
||||
const IGNORE_MESSAGE = Symbol("opfs-ignore-message");
|
||||
/** A reply whose correlation could not be read without running foreign code. */
|
||||
const UNREADABLE_CORRELATION = Symbol("opfs-unreadable-correlation");
|
||||
|
||||
function readCorrelation(
|
||||
source: unknown,
|
||||
): string | typeof IGNORE_MESSAGE | typeof UNREADABLE_CORRELATION {
|
||||
if (source === null || typeof source !== "object") return IGNORE_MESSAGE;
|
||||
let descriptor: PropertyDescriptor | undefined;
|
||||
try {
|
||||
descriptor = Object.getOwnPropertyDescriptor(source, "requestId");
|
||||
} catch {
|
||||
return UNREADABLE_CORRELATION;
|
||||
}
|
||||
if (!descriptor) return IGNORE_MESSAGE;
|
||||
// An accessor would have to be invoked to be read, and invoking foreign code
|
||||
// to find out who a message belongs to is exactly what must not happen.
|
||||
if (!("value" in descriptor)) return UNREADABLE_CORRELATION;
|
||||
const value = descriptor.value;
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 128
|
||||
? value
|
||||
: UNREADABLE_CORRELATION;
|
||||
}
|
||||
|
||||
function hasOnlyOwnDataKeys(
|
||||
source: object,
|
||||
allowed: ReadonlySet<string>,
|
||||
): boolean {
|
||||
try {
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) return false;
|
||||
for (const key of Object.getOwnPropertyNames(source)) {
|
||||
if (!allowed.has(key)) return false;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWorkerResponse(
|
||||
value: unknown,
|
||||
expectedKind: OpfsWorkerRequest["kind"],
|
||||
): OpfsWorkerResponse | null {
|
||||
// NS-06. Total by construction: every reflection operation below can be
|
||||
// trapped, and a decoder that throws would strand the request it was
|
||||
// decoding rather than closing it.
|
||||
try {
|
||||
return decodeWorkerResponseUnguarded(value, expectedKind);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWorkerResponseUnguarded(
|
||||
value: unknown,
|
||||
expectedKind: OpfsWorkerRequest["kind"],
|
||||
): OpfsWorkerResponse | null {
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
if (!hasOnlyOwnDataKeys(value, WORKER_RESPONSE_KEYS)) return null;
|
||||
const requestId = ownStringField(value, "requestId");
|
||||
if (requestId === null || requestId.length > 128) return null;
|
||||
if (ownField(value, "protocolVersion") !== OPFS_WORKER_PROTOCOL_VERSION) {
|
||||
return null;
|
||||
}
|
||||
if (ownField(value, "kind") !== expectedKind) return null;
|
||||
const ok = ownField(value, "ok");
|
||||
if (typeof ok !== "boolean") return null;
|
||||
|
||||
if (ok) {
|
||||
if (Object.hasOwn(value, "failure")) return null;
|
||||
return Object.freeze(
|
||||
Object.hasOwn(value, "value")
|
||||
? {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expectedKind,
|
||||
ok: true,
|
||||
value: ownField(value, "value"),
|
||||
}
|
||||
: {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expectedKind,
|
||||
ok: true,
|
||||
},
|
||||
) as OpfsWorkerResponse;
|
||||
}
|
||||
|
||||
if (Object.hasOwn(value, "value")) return null;
|
||||
const failure = ownField(value, "failure");
|
||||
if (failure === null || typeof failure !== "object") return null;
|
||||
if (!hasOnlyOwnDataKeys(failure, WORKER_FAILURE_KEYS)) return null;
|
||||
const code = ownField(failure, "code");
|
||||
const retryable = ownField(failure, "retryable");
|
||||
if (!isBrowserDataFailureCode(code) || typeof retryable !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expectedKind,
|
||||
ok: false,
|
||||
failure: Object.freeze({ code, retryable }),
|
||||
}) as OpfsWorkerResponse;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
DurableObjectDescriptor,
|
||||
OpfsCapabilities,
|
||||
OpfsCleanupEffect,
|
||||
OpfsPhysicalGenerationId,
|
||||
OpfsPreparedObject,
|
||||
OpfsStorageScope,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
@@ -12,18 +14,34 @@ import type {
|
||||
TransferProgress,
|
||||
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
/**
|
||||
* STO-07. Every request and response carries the protocol version and the
|
||||
* response echoes its request kind, so a page/worker release mismatch or a
|
||||
* malformed reply closes as `INCOMPATIBLE` instead of being decoded as a
|
||||
* successful value of the wrong shape.
|
||||
*/
|
||||
export const OPFS_WORKER_PROTOCOL_VERSION = 2 as const;
|
||||
|
||||
export type OpfsWorkerRequestEnvelope = Readonly<{
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
}>;
|
||||
|
||||
export type OpfsWorkerRequest =
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "CAPABILITIES";
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "BEGIN_PUT";
|
||||
transactionId: string;
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
generation: number;
|
||||
/** STO-01. Transaction-unique physical fencing token for new writes. */
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
declaredByteLength: number;
|
||||
mediaType: string;
|
||||
createdAtEpochMs: number;
|
||||
@@ -32,6 +50,7 @@ export type OpfsWorkerRequest =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "APPEND_CHUNK";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
@@ -40,29 +59,35 @@ export type OpfsWorkerRequest =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "FINISH_PUT";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "ABORT_PUT";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
physicalGenerationId?: OpfsPhysicalGenerationId;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "VERIFY_OBJECT";
|
||||
preparedObject: OpfsPreparedObject;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "READ_CHUNK";
|
||||
preparedObject: OpfsPreparedObject;
|
||||
sequence: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "REMOVE_OBJECT";
|
||||
scope: OpfsStorageScope;
|
||||
objectId: string;
|
||||
@@ -70,18 +95,27 @@ export type OpfsWorkerRequest =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "CLEANUP_TRANSACTION";
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
/**
|
||||
* STO-01. When present, cleanup deletes only this exact physical
|
||||
* generation and can never touch a newer transaction that reused the same
|
||||
* logical generation.
|
||||
*/
|
||||
physicalGenerationId?: OpfsPhysicalGenerationId;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "FINALIZE_PUT";
|
||||
transactionId: string;
|
||||
preparedObject: OpfsPreparedObject;
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "LIST_ORPHAN_CANDIDATES";
|
||||
scope: OpfsStorageScope;
|
||||
olderThanEpochMs: number;
|
||||
@@ -89,6 +123,7 @@ export type OpfsWorkerRequest =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: "DELETE_ORPHAN_CHUNK";
|
||||
scope: OpfsStorageScope;
|
||||
digestHex: string;
|
||||
@@ -98,7 +133,7 @@ export type OpfsWorkerRequest =
|
||||
export type OpfsWorkerRequestBody =
|
||||
OpfsWorkerRequest extends infer Request
|
||||
? Request extends OpfsWorkerRequest
|
||||
? Omit<Request, "requestId">
|
||||
? Omit<Request, "requestId" | "protocolVersion">
|
||||
: never
|
||||
: never;
|
||||
|
||||
@@ -121,9 +156,12 @@ export type OpfsOrphanDeleteResult = Readonly<{
|
||||
export type OpfsWorkerResponse =
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: OpfsWorkerRequest["kind"];
|
||||
ok: true;
|
||||
value?:
|
||||
| OpfsCapabilities
|
||||
| OpfsCleanupEffect
|
||||
| OpfsPreparedObject
|
||||
| ArrayBuffer
|
||||
| boolean
|
||||
@@ -132,18 +170,33 @@ export type OpfsWorkerResponse =
|
||||
}>
|
||||
| Readonly<{
|
||||
requestId: string;
|
||||
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
|
||||
kind: OpfsWorkerRequest["kind"];
|
||||
ok: false;
|
||||
failure: OpfsWorkerFailure;
|
||||
}>;
|
||||
|
||||
export type PreparePhysicalObjectRequest = Readonly<{
|
||||
transactionId: string;
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
descriptor: Omit<DurableObjectDescriptor, "integrity">;
|
||||
source: ByteSource;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: TransferProgress) => void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* STO-01. The single compensation entry point. The coordinator owns it and
|
||||
* passes a composition-owned bounded signal, never the already aborted caller
|
||||
* signal.
|
||||
*/
|
||||
export type AbortPreparedPutRequest = Readonly<{
|
||||
scope: OpfsStorageScope;
|
||||
transactionId: string;
|
||||
physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The coordinator depends on this technology-neutral worker gateway. The
|
||||
* browser implementation below the boundary owns Worker, MessageEvent and
|
||||
@@ -172,7 +225,10 @@ export interface OpfsWorkerGateway {
|
||||
scope: OpfsStorageScope,
|
||||
transactionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
): Promise<BrowserDataResult<OpfsCleanupEffect>>;
|
||||
abortPreparedPut(
|
||||
request: AbortPreparedPutRequest,
|
||||
): Promise<BrowserDataResult<OpfsCleanupEffect>>;
|
||||
finalizePut(
|
||||
transactionId: string,
|
||||
preparedObject: OpfsPreparedObject,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
OpfsCapabilities,
|
||||
OpfsChunkReference,
|
||||
OpfsCleanupEffect,
|
||||
OpfsPhysicalGenerationId,
|
||||
OpfsPreparedObject,
|
||||
OpfsStorageScope,
|
||||
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
||||
@@ -13,6 +15,9 @@ import {
|
||||
isValidOpfsStorageScope,
|
||||
type OpfsRuntimePolicy,
|
||||
} from "./opfs-policy.ts";
|
||||
import {
|
||||
OPFS_WORKER_PROTOCOL_VERSION,
|
||||
} from "./opfs-worker-protocol.ts";
|
||||
import type {
|
||||
OpfsOrphanCandidateBatch,
|
||||
OpfsOrphanDeleteResult,
|
||||
@@ -54,6 +59,7 @@ type ActivePut = {
|
||||
readonly scope: OpfsPreparedObject["descriptor"]["scope"];
|
||||
readonly objectId: string;
|
||||
readonly generation: number;
|
||||
readonly physicalGenerationId: OpfsPhysicalGenerationId;
|
||||
readonly declaredByteLength: number;
|
||||
readonly mediaType: string;
|
||||
readonly createdAtEpochMs: number;
|
||||
@@ -151,12 +157,21 @@ export async function startBrowserOpfsDedicatedWorker(
|
||||
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(requestData.requestId, mapRuntimeFailure(error)),
|
||||
failure(
|
||||
correlation.requestId,
|
||||
mapRuntimeFailure(error),
|
||||
correlation.kind,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -193,32 +208,50 @@ export function createOpfsWorkerRuntime(
|
||||
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 {
|
||||
if (!isWorkerRequest(request)) {
|
||||
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
||||
}
|
||||
switch (request.kind) {
|
||||
case "CAPABILITIES":
|
||||
return success(request.requestId, capabilities);
|
||||
return success(request.requestId, request.kind, capabilities);
|
||||
case "BEGIN_PUT":
|
||||
await beginPut(request);
|
||||
return success(request.requestId);
|
||||
return success(request.requestId, request.kind);
|
||||
case "APPEND_CHUNK":
|
||||
await appendChunk(request);
|
||||
return success(request.requestId);
|
||||
return success(request.requestId, request.kind);
|
||||
case "FINISH_PUT":
|
||||
return success(request.requestId, await finishPut(request));
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await finishPut(request),
|
||||
);
|
||||
case "ABORT_PUT":
|
||||
await abortPut(request.scope, request.transactionId);
|
||||
return success(request.requestId);
|
||||
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,
|
||||
@@ -230,22 +263,28 @@ export function createOpfsWorkerRuntime(
|
||||
request.objectId,
|
||||
request.generation,
|
||||
);
|
||||
return success(request.requestId);
|
||||
return success(request.requestId, request.kind);
|
||||
case "CLEANUP_TRANSACTION":
|
||||
await cleanupTransaction(
|
||||
request.scope,
|
||||
request.transactionId,
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await cleanupTransaction(
|
||||
request.scope,
|
||||
request.transactionId,
|
||||
true,
|
||||
request.physicalGenerationId,
|
||||
),
|
||||
);
|
||||
return success(request.requestId);
|
||||
case "FINALIZE_PUT":
|
||||
await finalizePut(
|
||||
request.transactionId,
|
||||
request.preparedObject,
|
||||
);
|
||||
return success(request.requestId);
|
||||
return success(request.requestId, request.kind);
|
||||
case "LIST_ORPHAN_CANDIDATES":
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await listOrphanCandidates(
|
||||
request.scope,
|
||||
request.olderThanEpochMs,
|
||||
@@ -255,6 +294,7 @@ export function createOpfsWorkerRuntime(
|
||||
case "DELETE_ORPHAN_CHUNK":
|
||||
return success(
|
||||
request.requestId,
|
||||
request.kind,
|
||||
await deleteOrphanChunk(
|
||||
request.scope,
|
||||
request.digestHex,
|
||||
@@ -263,7 +303,14 @@ export function createOpfsWorkerRuntime(
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return failure(request.requestId, mapRuntimeFailure(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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -278,6 +325,7 @@ export function createOpfsWorkerRuntime(
|
||||
!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 ||
|
||||
@@ -327,6 +375,7 @@ export function createOpfsWorkerRuntime(
|
||||
scope: request.scope,
|
||||
objectId: request.objectId,
|
||||
generation: request.generation,
|
||||
physicalGenerationId: request.physicalGenerationId,
|
||||
declaredByteLength: request.declaredByteLength,
|
||||
mediaType: request.mediaType,
|
||||
createdAtEpochMs: request.createdAtEpochMs,
|
||||
@@ -457,7 +506,8 @@ export function createOpfsWorkerRuntime(
|
||||
);
|
||||
assertActivePut(transactionKey, put, true);
|
||||
const prepared: OpfsPreparedObject = Object.freeze({
|
||||
physicalSchemaVersion: 1,
|
||||
physicalSchemaVersion: 2,
|
||||
physicalGenerationId: put.physicalGenerationId,
|
||||
descriptor: Object.freeze({
|
||||
objectId: put.objectId,
|
||||
scope: put.scope,
|
||||
@@ -501,10 +551,13 @@ export function createOpfsWorkerRuntime(
|
||||
async function abortPut(
|
||||
scope: OpfsPreparedObject["descriptor"]["scope"],
|
||||
transactionId: string,
|
||||
): Promise<void> {
|
||||
physicalGenerationId?: OpfsPhysicalGenerationId,
|
||||
): Promise<OpfsCleanupEffect> {
|
||||
if (
|
||||
!isValidOpfsStorageScope(scope) ||
|
||||
!SAFE_TRANSACTION_ID.test(transactionId)
|
||||
!SAFE_TRANSACTION_ID.test(transactionId) ||
|
||||
(physicalGenerationId !== undefined &&
|
||||
!isPhysicalGenerationId(physicalGenerationId))
|
||||
) {
|
||||
throw new OpfsRuntimeFailure("INVALID_INPUT");
|
||||
}
|
||||
@@ -518,15 +571,33 @@ export function createOpfsWorkerRuntime(
|
||||
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();
|
||||
}
|
||||
await removePhysicalGeneration(
|
||||
active.scope,
|
||||
active.objectId,
|
||||
active.generation,
|
||||
);
|
||||
}
|
||||
await cleanupTransaction(scope, transactionId);
|
||||
return await cleanupTransaction(
|
||||
scope,
|
||||
transactionId,
|
||||
true,
|
||||
physicalGenerationId,
|
||||
);
|
||||
}
|
||||
|
||||
async function runActivePutOperation<Value>(
|
||||
@@ -571,20 +642,30 @@ export function createOpfsWorkerRuntime(
|
||||
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();
|
||||
}
|
||||
await removePhysicalGeneration(
|
||||
put.scope,
|
||||
put.objectId,
|
||||
put.generation,
|
||||
);
|
||||
await cleanupTransaction(put.scope, put.transactionId);
|
||||
}
|
||||
|
||||
async function removePhysicalGeneration(
|
||||
scope: OpfsStorageScope,
|
||||
objectId: string,
|
||||
generation: number,
|
||||
physicalGenerationId: OpfsPhysicalGenerationId | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const objectDirectory = await getDirectory(
|
||||
@@ -599,7 +680,7 @@ export function createOpfsWorkerRuntime(
|
||||
);
|
||||
await removeEntryIfPresent(
|
||||
objectDirectory,
|
||||
String(generation),
|
||||
generationSegmentFor(generation, physicalGenerationId),
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -714,17 +795,68 @@ export function createOpfsWorkerRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): Promise<void> {
|
||||
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(
|
||||
@@ -733,32 +865,38 @@ export function createOpfsWorkerRuntime(
|
||||
false,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return;
|
||||
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;
|
||||
{
|
||||
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;
|
||||
}
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
const target = extractReceiptPhysicalTarget(receipt, scope);
|
||||
if (!target) {
|
||||
throw new OpfsRuntimeFailure("CORRUPT_DATA");
|
||||
}
|
||||
await removePhysicalGeneration(
|
||||
scope,
|
||||
target.objectId,
|
||||
target.generation,
|
||||
);
|
||||
await removeEntryIfPresent(staging, transactionId, true);
|
||||
return CLEANUP_CLEANED;
|
||||
}
|
||||
await removeEntryIfPresent(staging, transactionId, true);
|
||||
}
|
||||
|
||||
async function finalizePut(
|
||||
@@ -788,16 +926,19 @@ export function createOpfsWorkerRuntime(
|
||||
for await (const [name, handle] of objectDirectory.entries()) {
|
||||
if (
|
||||
handle.kind === "directory" &&
|
||||
/^\d+$/u.test(name) &&
|
||||
name !== String(descriptor.generation)
|
||||
isGenerationSegment(name) &&
|
||||
name !== preparedGenerationSegment(preparedObject)
|
||||
) {
|
||||
await objectDirectory.removeEntry(name, { recursive: true });
|
||||
}
|
||||
}
|
||||
await cleanupTransaction(descriptor.scope, transactionId, false);
|
||||
// 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 cleanupTransaction(
|
||||
await cleanupTransactionLocked(
|
||||
preparedObject.descriptor.scope,
|
||||
transactionId,
|
||||
false,
|
||||
@@ -980,7 +1121,11 @@ export function createOpfsWorkerRuntime(
|
||||
function extractReceiptPhysicalTarget(
|
||||
receipt: unknown,
|
||||
scope: OpfsStorageScope,
|
||||
): Readonly<{ objectId: string; generation: number }> | null {
|
||||
): 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") {
|
||||
@@ -989,7 +1134,15 @@ export function createOpfsWorkerRuntime(
|
||||
typeof record.generation === "number" &&
|
||||
Number.isSafeInteger(record.generation) &&
|
||||
record.generation > 0
|
||||
? { objectId: record.objectId, generation: record.generation }
|
||||
? {
|
||||
objectId: record.objectId,
|
||||
generation: record.generation,
|
||||
physicalGenerationId: isPhysicalGenerationId(
|
||||
record.physicalGenerationId,
|
||||
)
|
||||
? record.physicalGenerationId
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (
|
||||
@@ -997,9 +1150,14 @@ export function createOpfsWorkerRuntime(
|
||||
isPreparedObjectSafe(record.preparedObject, dependencies.policy) &&
|
||||
sameScope(record.preparedObject.descriptor.scope, scope)
|
||||
) {
|
||||
const prepared = record.preparedObject;
|
||||
return {
|
||||
objectId: record.preparedObject.descriptor.objectId,
|
||||
generation: record.preparedObject.descriptor.generation,
|
||||
objectId: prepared.descriptor.objectId,
|
||||
generation: prepared.descriptor.generation,
|
||||
physicalGenerationId:
|
||||
prepared.physicalSchemaVersion === 2
|
||||
? prepared.physicalGenerationId
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -1019,11 +1177,12 @@ export function createOpfsWorkerRuntime(
|
||||
|
||||
async function writeReceipt(put: ActivePut): Promise<void> {
|
||||
await writeJsonAtomic(receiptPath(put.scope, put.transactionId), {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
phase: "PREPARING",
|
||||
scope: put.scope,
|
||||
objectId: put.objectId,
|
||||
generation: put.generation,
|
||||
physicalGenerationId: put.physicalGenerationId,
|
||||
declaredByteLength: put.declaredByteLength,
|
||||
chunks: put.chunks,
|
||||
});
|
||||
@@ -1296,6 +1455,52 @@ async function removeEntryIfPresent(
|
||||
}
|
||||
}
|
||||
|
||||
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 [
|
||||
@@ -1303,7 +1508,7 @@ function manifestPath(preparedObject: OpfsPreparedObject): readonly string[] {
|
||||
"objects",
|
||||
descriptor.objectId.slice(0, 2),
|
||||
descriptor.objectId,
|
||||
String(descriptor.generation),
|
||||
preparedGenerationSegment(preparedObject),
|
||||
"manifest.json",
|
||||
];
|
||||
}
|
||||
@@ -1373,7 +1578,8 @@ function receiptBelongsToScope(
|
||||
if (!receipt || typeof receipt !== "object") return false;
|
||||
const record = receipt as Record<string, unknown>;
|
||||
if (
|
||||
record.schemaVersion !== 1 ||
|
||||
// v1 receipts stay readable through the rollback window.
|
||||
(record.schemaVersion !== 1 && record.schemaVersion !== 2) ||
|
||||
(record.phase !== "PREPARING" && record.phase !== "FILES_READY")
|
||||
) {
|
||||
return false;
|
||||
@@ -1464,6 +1670,20 @@ function stableJson(value: unknown): string {
|
||||
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,
|
||||
@@ -1472,7 +1692,7 @@ function isPreparedObjectSafe(
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
!("physicalSchemaVersion" in value) ||
|
||||
value.physicalSchemaVersion !== 1 ||
|
||||
!isSupportedPhysicalSchema(value) ||
|
||||
!("descriptor" in value) ||
|
||||
!value.descriptor ||
|
||||
typeof value.descriptor !== "object" ||
|
||||
@@ -1564,9 +1784,11 @@ function isPreparedObjectSafe(
|
||||
|
||||
function success(
|
||||
requestId: string,
|
||||
kind: OpfsWorkerRequest["kind"],
|
||||
value?: OpfsWorkerResponse extends infer _Response
|
||||
?
|
||||
| OpfsCapabilities
|
||||
| OpfsCleanupEffect
|
||||
| OpfsPreparedObject
|
||||
| ArrayBuffer
|
||||
| boolean
|
||||
@@ -1575,15 +1797,33 @@ function success(
|
||||
: never,
|
||||
): OpfsWorkerResponse {
|
||||
return value === undefined
|
||||
? { requestId, ok: true }
|
||||
: { requestId, ok: true, value };
|
||||
? {
|
||||
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, ok: false, failure: workerFailure };
|
||||
return {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind,
|
||||
ok: false,
|
||||
failure: workerFailure,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuntimeFailure(error: unknown): OpfsWorkerFailure {
|
||||
@@ -1640,12 +1880,40 @@ function hasRequestId(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user