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:
DongHyeonka
2026-08-15 12:04:58 +09:00
co-authored by Claude Opus 5
parent 002ba3624e
commit 4bff9ca151
142 changed files with 23010 additions and 1544 deletions
@@ -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),
+5 -1
View File
@@ -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";
+245 -59
View File
@@ -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,
+339 -71
View File
@@ -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,
);
}