fix: bound browser transfer leases and effect reporting

BT-X-01: add the shared abortable-operation utility with golden tests for first
terminal owner, idempotent close, listener and timer cleanup under a throwing
scheduler, observed late rejection and late-handle compensation. It carries no
subsystem result taxonomy.

BT-PRE-01: make the presigned download lease lazy and single-start. open() now
validates, claims and consumes the capability without any network I/O; the
fetch, the transfer deadline and the expiry recheck happen at first stream
consumption. The source gained close(), which discards an unused lease with no
I/O and otherwise cancels the body and releases the scope exactly once.

BT-UP-01: require removeEventListener in the AbortSignal structural guard and
isolate release cleanup so a hostile facade cannot replace a typed terminal
result with a rejection.

BT-UP-03: deleteDatabase cannot be cancelled after dispatch, so a blocked
deadline now returns PENDING with effect UNKNOWN instead of a failure that reads
as NOT_APPLIED. A realm-scoped (factory, databaseName) registry prevents
recreating the partition until the native request settles.

BT-UP-04: reject non-finite and negative upload clocks as a dependency failure
instead of letting them bypass every capability expiry comparison.

BT-IMG-02: replace the naive Cache-Control quote stripping with a quote- and
escape-aware tokenizer, so max-age="60 or 60" is no longer read as 60 and a
comma inside a quoted extension is not a directive boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 00:13:54 +09:00
co-authored by Claude Opus 5
parent 8f67974f68
commit cc4e875c2d
13 changed files with 831 additions and 134 deletions
@@ -116,23 +116,23 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
| ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence | | ID | Activation | Red test command | Fix commit/PR | Rollout state | Rollback trigger | Evidence |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| BT-PRE-01 | Presigned `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | download lease leak | — | | BT-PRE-01 | Presigned `AVAILABLE_NOT_COMPOSED` | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | `fix: lazy presigned download leases` | `FIXED_NOT_RELEASED` | download lease leak | Red lazy-lease cases → green 29/29; `open()` performs no network I/O and `close()` is idempotent |
| BT-PRE-02 | Wire contract gap | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | provider `POLICY_REJECTED` spike | — | | BT-PRE-02 | Wire contract gap | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | provider `POLICY_REJECTED` spike | — |
| BT-PRE-03 | Presigned provider | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | timeout not bounding fetch | — | | BT-PRE-03 | Presigned provider | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | timeout not bounding fetch | — |
| BT-PRE-04 | Presigned vault | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | issuer/consumer split break | — | | BT-PRE-04 | Presigned vault | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | issuer/consumer split break | — |
| BT-PRE-05 | Provider path decoding | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | legitimate key rejection | — | | BT-PRE-05 | Provider path decoding | `corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts` | — | `NOT_STARTED` | legitimate key rejection | — |
| BT-UP-01 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | — | `NOT_STARTED` | signal facade rejection | — | | BT-UP-01 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | `fix: harden resumable upload transport contracts` | `FIXED_NOT_RELEASED` | signal facade rejection | `isAbortSignal` now requires `removeEventListener` and release cleanup is isolated |
| BT-UP-02 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | — | `NOT_STARTED` | clock injection break | — | | BT-UP-02 | Resumable transport | `corepack pnpm exec vitest run tests/unit/resumable-upload-fetch-transport.test.ts` | — | `NOT_STARTED` | clock injection break | — |
| BT-UP-03 | Resumable checkpoint store | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | — | `NOT_STARTED` | pending-delete registry growth | — | | BT-UP-03 | Resumable checkpoint store | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | `fix: report unknown IndexedDB delete effects` | `FIXED_NOT_RELEASED` | pending-delete registry growth | Red blocked-deadline case → green `PENDING`/`UNKNOWN`; a realm-scoped registry blocks recreating the partition |
| BT-UP-04 | Presigned part executor | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | — | `NOT_STARTED` | expiry check rejection | — | | BT-UP-04 | Presigned part executor | `corepack pnpm exec vitest run tests/unit/resumable-upload-checkpoint.test.ts` | `fix: harden resumable upload transport contracts` | `FIXED_NOT_RELEASED` | expiry check rejection | Non-finite and negative clocks return `UNAVAILABLE`/`RESUME` instead of bypassing expiry |
| BT-UP-05 | Refactor | `corepack pnpm exec vitest run tests/unit/resumable-upload-runtime.test.ts` | — | `NOT_STARTED` | characterization drift | — | | BT-UP-05 | Refactor | `corepack pnpm exec vitest run tests/unit/resumable-upload-runtime.test.ts` | — | `NOT_STARTED` | characterization drift | — |
| BT-UP-06 | Refactor | `corepack pnpm exec vitest run tests/unit/resumable-upload-runtime.test.ts` | — | `NOT_STARTED` | drain not quiescent | — | | BT-UP-06 | Refactor | `corepack pnpm exec vitest run tests/unit/resumable-upload-runtime.test.ts` | — | `NOT_STARTED` | drain not quiescent | — |
| BT-UP-07 | Documented gap (Web Locks matrix) | promotion evidence | — | `PROMOTION_BLOCKED` | n/a | — | | BT-UP-07 | Documented gap (Web Locks matrix) | promotion evidence | — | `PROMOTION_BLOCKED` | n/a | — |
| BT-IMG-01 | Type-contract change | `corepack pnpm check:types:test` fixture | — | `NOT_STARTED` | caller compile break | — | | BT-IMG-01 | Type-contract change | `corepack pnpm check:types:test` fixture | — | `NOT_STARTED` | caller compile break | — |
| BT-IMG-02 | Image probe | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | — | `NOT_STARTED` | Cache-Control parse rejection | — | | BT-IMG-02 | Image probe | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | `fix: parse Cache-Control with quote awareness` | `FIXED_NOT_RELEASED` | Cache-Control parse rejection | Red unmatched-quote cases → green 25/25 |
| BT-IMG-03 | Refactor | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | — | `NOT_STARTED` | characterization drift | — | | BT-IMG-03 | Refactor | `corepack pnpm exec vitest run tests/unit/image-cdn-runtime.test.ts` | — | `NOT_STARTED` | characterization drift | — |
| BT-IMG-04 | Documented gap (descriptor provider) | promotion evidence | — | `PROMOTION_BLOCKED` | n/a | — | | BT-IMG-04 | Documented gap (descriptor provider) | promotion evidence | — | `PROMOTION_BLOCKED` | n/a | — |
| BT-X-01 | Shared abort mechanics | `corepack pnpm exec vitest run tests/unit/abortable-operation.test.ts` | — | `NOT_STARTED` | late-result compensation regression | — | | BT-X-01 | Shared abort mechanics | `corepack pnpm exec vitest run tests/unit/abortable-operation.test.ts` | `fix: share abort and deadline mechanics` | `FIXED_NOT_RELEASED` | late-result compensation regression | Golden suite 8/8: first terminal owner, idempotent close, throwing scheduler, observed late rejection, late-handle compensation |
### Service Worker and Web Push (`docs/reviews/adapters/05-service-worker-and-web-push.md`) ### Service Worker and Web Push (`docs/reviews/adapters/05-service-worker-and-web-push.md`)
@@ -336,6 +336,77 @@ function validResponseHeaders(
); );
} }
/**
* BT-IMG-02. Quote- and escape-aware Cache-Control tokenizer.
*
* A naive comma split plus `replace(/^"|"$/g, "")` accepted `max-age="60` and
* `max-age=60"` as the number 60, so a malformed policy could be approved as an
* immutable public response. A comma inside a quoted extension value is also
* not a directive boundary.
*/
function splitCacheControlDirectives(value: string): string[] | null {
const parts: string[] = [];
let current = "";
let inQuotes = false;
let escaped = false;
for (const character of value) {
if (escaped) {
const code = character.codePointAt(0) ?? 0;
// quoted-pair may not carry a bare control character.
if (code <= 0x1f || code === 0x7f) return null;
current += character;
escaped = false;
continue;
}
if (inQuotes && character === "\\") {
escaped = true;
current += character;
continue;
}
if (character === '"') {
inQuotes = !inQuotes;
current += character;
continue;
}
if (character === "," && !inQuotes) {
parts.push(current);
current = "";
continue;
}
current += character;
}
// An unterminated quoted-string or a dangling escape is malformed.
if (inQuotes || escaped) return null;
parts.push(current);
return parts;
}
function unquoteCacheControlValue(rawValue: string): string | null {
if (!rawValue.startsWith('"')) {
// A bare value may not contain a quote at all.
return rawValue.includes('"') ? null : rawValue;
}
if (rawValue.length < 2 || !rawValue.endsWith('"')) return null;
const inner = rawValue.slice(1, -1);
let unquoted = "";
let escaped = false;
for (const character of inner) {
if (escaped) {
unquoted += character;
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
// An unescaped quote inside the string means the quoting is unbalanced.
if (character === '"') return null;
unquoted += character;
}
return escaped ? null : unquoted;
}
function parseCacheControl( function parseCacheControl(
value: string | null, value: string | null,
): ReadonlyMap<string, string | true> | null { ): ReadonlyMap<string, string | true> | null {
@@ -348,7 +419,10 @@ function parseCacheControl(
"public", "public",
]); ]);
const directives = new Map<string, string | true>(); const directives = new Map<string, string | true>();
for (const part of value?.split(",") ?? []) { if (value === null) return directives;
const parts = splitCacheControlDirectives(value);
if (!parts) return null;
for (const part of parts) {
const trimmedPart = part.trim(); const trimmedPart = part.trim();
const separator = trimmedPart.indexOf("="); const separator = trimmedPart.indexOf("=");
const name = ( const name = (
@@ -367,7 +441,9 @@ function parseCacheControl(
if (flagDirectives.has(name)) return null; if (flagDirectives.has(name)) return null;
const rawValue = trimmedPart.slice(separator + 1).trim(); const rawValue = trimmedPart.slice(separator + 1).trim();
if (rawValue === "") return null; if (rawValue === "") return null;
directives.set(name, rawValue.replace(/^"|"$/gu, "")); const unquoted = unquoteCacheControlValue(rawValue);
if (unquoted === null) return null;
directives.set(name, unquoted);
} }
return directives; return directives;
} }
@@ -154,41 +154,34 @@ export function createPresignedTransferExecutor(
const consumed = consume(capability); const consumed = consume(capability);
if (!consumed.ok) return consumed; if (!consumed.ok) return consumed;
const scope = createAbortScope(signal, timeoutMs, scheduler); // BT-PRE-01. The lease is lazy and single-start: `open()` performs no
try { // network I/O, so the transfer deadline begins at first consumption and an
const response = await fetcher(binding.href, { // unused source can be discarded through `close()` without leaking a body,
method: "GET", // a timer or a listener.
headers: headersFor(binding), const source = createDownloadSource({
credentials: "omit", start: async (scope) =>
redirect: "error", await fetcher(binding.href, {
referrerPolicy: "no-referrer", method: "GET",
cache: "no-store", headers: headersFor(binding),
signal: scope.signal, credentials: "omit",
}); redirect: "error",
const validated = validateDownloadResponse( referrerPolicy: "no-referrer",
response, cache: "no-store",
binding, signal: scope.signal,
); }),
if (!validated.ok) { validateResponse: (response) =>
cancelBody(response); validateDownloadResponse(response, binding),
scope.release(); createScope: () => createAbortScope(signal, timeoutMs, scheduler),
return validated; recheckExpiry: () =>
} validateExpiry(capability, minimumRemainingLifetimeMs, now()),
const source = createDownloadSource({ binding,
response, capability,
binding, externalSignal: signal,
capability, hardMaxChunkBytes,
externalSignal: signal, createVerifier,
scope, observer,
hardMaxChunkBytes, });
createVerifier, return browserDataSuccess(source);
observer,
});
return browserDataSuccess(source);
} catch {
scope.release();
return transferFailure(signal, scope.timedOut());
}
} }
async function putUploadPart( async function putUploadPart(
@@ -406,29 +399,53 @@ export function createPresignedTransferExecutor(
} }
function createDownloadSource(input: Readonly<{ function createDownloadSource(input: Readonly<{
response: Response; start: (
scope: ReturnType<typeof createAbortScope>,
) => Promise<Response>;
validateResponse: (response: Response) => BrowserDataResult<unknown>;
createScope: () => ReturnType<typeof createAbortScope>;
recheckExpiry: () => BrowserDataResult<unknown>;
binding: PresignedCapabilityBinding; binding: PresignedCapabilityBinding;
capability: PresignedDownloadCapability; capability: PresignedDownloadCapability;
externalSignal: AbortSignal; externalSignal: AbortSignal;
scope: ReturnType<typeof createAbortScope>;
hardMaxChunkBytes: number; hardMaxChunkBytes: number;
createVerifier: ( createVerifier: (
expectedSha256: string, expectedSha256: string,
) => StreamingSha256Verifier; ) => StreamingSha256Verifier;
observer: BrowserDataObserver | undefined; observer: BrowserDataObserver | undefined;
}>): PresignedDownloadByteSource { }>): PresignedDownloadByteSource {
let started = false; /** BT-PRE-01. One state machine shared by `stream()` and `close()`. */
let state: "READY" | "STREAMING" | "CLOSED" = "READY";
let activeScope: ReturnType<typeof createAbortScope> | undefined;
let activeResponse: Response | undefined;
const releaseActive = () => {
if (activeResponse) {
cancelBody(activeResponse);
activeResponse = undefined;
}
if (activeScope) {
activeScope.release();
activeScope = undefined;
}
};
return Object.freeze({ return Object.freeze({
byteLength: input.capability.byteLength, byteLength: input.capability.byteLength,
capability: input.capability, capability: input.capability,
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const, integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
close() {
// READY -> CLOSED performs no I/O; STREAMING -> CLOSED cancels once.
if (state === "CLOSED") return;
state = "CLOSED";
releaseActive();
},
async *stream( async *stream(
consumerSignal: AbortSignal, consumerSignal: AbortSignal,
): AsyncIterable<BrowserDataResult<Uint8Array>> { ): AsyncIterable<BrowserDataResult<Uint8Array>> {
if (!isAbortSignal(consumerSignal)) { if (!isAbortSignal(consumerSignal)) {
started = true; state = "CLOSED";
cancelBody(input.response); releaseActive();
input.scope.release();
const failure = browserDataFailure( const failure = browserDataFailure(
"INVALID_INPUT", "INVALID_INPUT",
"PRESIGNED_TRANSFER", "PRESIGNED_TRANSFER",
@@ -437,7 +454,7 @@ function createDownloadSource(input: Readonly<{
yield failure; yield failure;
return; return;
} }
if (started) { if (state !== "READY") {
const failure = browserDataFailure( const failure = browserDataFailure(
"CONFLICT", "CONFLICT",
"PRESIGNED_TRANSFER", "PRESIGNED_TRANSFER",
@@ -449,7 +466,45 @@ function createDownloadSource(input: Readonly<{
yield failure; yield failure;
return; return;
} }
started = true; state = "STREAMING";
// The capability may have expired while the lease sat unused.
const stillActive = input.recheckExpiry();
if (!stillActive.ok) {
state = "CLOSED";
observeTransferResult(
input.observer,
"DOWNLOAD",
stillActive,
0,
);
yield stillActive as BrowserDataResult<never>;
return;
}
const scope = input.createScope();
activeScope = scope;
let response: Response;
try {
response = await input.start(scope);
} catch {
state = "CLOSED";
releaseActive();
const failure = transferFailure(
input.externalSignal,
scope.timedOut(),
);
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
yield failure;
return;
}
activeResponse = response;
const validated = input.validateResponse(response);
if (!validated.ok) {
state = "CLOSED";
releaseActive();
observeTransferResult(input.observer, "DOWNLOAD", validated, 0);
yield validated as BrowserDataResult<never>;
return;
}
let combined: let combined:
| ReturnType<typeof combineConsumerAbort> | ReturnType<typeof combineConsumerAbort>
| undefined; | undefined;
@@ -471,13 +526,13 @@ function createDownloadSource(input: Readonly<{
}; };
try { try {
combined = combineConsumerAbort( combined = combineConsumerAbort(
input.scope, scope,
consumerSignal, consumerSignal,
); );
const verifier = input.createVerifier( const verifier = input.createVerifier(
input.capability.expectedSha256, input.capability.expectedSha256,
); );
if (!input.response.body) { if (!response.body) {
let verified = false; let verified = false;
try { try {
verified = verified =
@@ -492,7 +547,7 @@ function createDownloadSource(input: Readonly<{
yield fail("INTEGRITY_FAILED"); yield fail("INTEGRITY_FAILED");
return; return;
} }
reader = input.response.body.getReader(); reader = response.body.getReader();
while (true) { while (true) {
if ( if (
input.externalSignal.aborted || input.externalSignal.aborted ||
@@ -501,7 +556,7 @@ function createDownloadSource(input: Readonly<{
yield fail("ABORTED"); yield fail("ABORTED");
return; return;
} }
if (input.scope.timedOut()) { if (scope.timedOut()) {
yield fail("UNAVAILABLE", { yield fail("UNAVAILABLE", {
retryable: true, retryable: true,
recovery: "REISSUE_CAPABILITY", recovery: "REISSUE_CAPABILITY",
@@ -510,7 +565,7 @@ function createDownloadSource(input: Readonly<{
} }
const result = await readWithSignal( const result = await readWithSignal(
reader, reader,
input.scope.signal, scope.signal,
); );
if (result.done) break; if (result.done) break;
const chunk = result.value; const chunk = result.value;
@@ -544,7 +599,7 @@ function createDownloadSource(input: Readonly<{
yield fail("ABORTED"); yield fail("ABORTED");
return; return;
} }
if (input.scope.timedOut()) { if (scope.timedOut()) {
yield fail("UNAVAILABLE", { yield fail("UNAVAILABLE", {
retryable: true, retryable: true,
recovery: "REISSUE_CAPABILITY", recovery: "REISSUE_CAPABILITY",
@@ -590,7 +645,7 @@ function createDownloadSource(input: Readonly<{
consumerSignal.aborted consumerSignal.aborted
) { ) {
yield fail("ABORTED"); yield fail("ABORTED");
} else if (input.scope.timedOut()) { } else if (scope.timedOut()) {
yield fail("UNAVAILABLE", { yield fail("UNAVAILABLE", {
retryable: true, retryable: true,
recovery: "REISSUE_CAPABILITY", recovery: "REISSUE_CAPABILITY",
@@ -605,14 +660,18 @@ function createDownloadSource(input: Readonly<{
combined?.release(); combined?.release();
if (!completed) { if (!completed) {
if (reader) cancelReader(reader); if (reader) cancelReader(reader);
else cancelBody(input.response); else cancelBody(response);
} }
try { try {
reader?.releaseLock(); reader?.releaseLock();
} catch { } catch {
// Reader cleanup cannot change stream success or failure. // Reader cleanup cannot change stream success or failure.
} }
input.scope.release(); // The lease is terminal once its single stream ends; cleanup runs once.
state = "CLOSED";
activeResponse = undefined;
activeScope = undefined;
scope.release();
observeBrowserData(input.observer, { observeBrowserData(input.observer, {
operation: "DOWNLOAD", operation: "DOWNLOAD",
outcome: completed outcome: completed
@@ -576,8 +576,18 @@ function createFetchAttempt(
terminal, terminal,
terminalKind: () => terminalKind, terminalKind: () => terminalKind,
release() { release() {
clearTimeout(timer); // BT-UP-01. Cleanup is best effort and must never replace the already
parent.removeEventListener("abort", abort); // classified terminal result with a rejection.
try {
clearTimeout(timer);
} catch {
// A hostile scheduler cannot block listener release below.
}
try {
parent.removeEventListener("abort", abort);
} catch {
// A hostile signal facade cannot break the typed result.
}
}, },
}); });
} }
@@ -667,12 +677,18 @@ function boundedPositiveInteger(
return value; return value;
} }
/**
* BT-UP-01. The structural guard must cover every method cleanup will call.
* Admitting a signal without `removeEventListener` turned a `finally` into a
* Promise rejection instead of the typed terminal result.
*/
function isAbortSignal(value: unknown): value is AbortSignal { function isAbortSignal(value: unknown): value is AbortSignal {
return Boolean( return Boolean(
value && value &&
typeof value === "object" && typeof value === "object" &&
typeof (value as AbortSignal).aborted === "boolean" && typeof (value as AbortSignal).aborted === "boolean" &&
typeof (value as AbortSignal).addEventListener === "function", typeof (value as AbortSignal).addEventListener === "function" &&
typeof (value as AbortSignal).removeEventListener === "function",
); );
} }
@@ -2,6 +2,7 @@ import type {
ResumableUploadCheckpointAdmin, ResumableUploadCheckpointAdmin,
ResumableUploadCheckpoint, ResumableUploadCheckpoint,
ResumableUploadCheckpointStore, ResumableUploadCheckpointStore,
PartitionDeleteOutcome,
} from "../../../application/ports/browser-transfer/resumable-upload.ts"; } from "../../../application/ports/browser-transfer/resumable-upload.ts";
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts"; import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
import { import {
@@ -21,6 +22,24 @@ const GOVERNANCE_STORE = "governance";
const GOVERNANCE_KEY = "scope-binding"; const GOVERNANCE_KEY = "scope-binding";
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000; const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
/**
* BT-UP-03. Per-realm registry keyed by `(IDBFactory identity, databaseName)`.
* It prevents this realm from recreating a store whose deletion is still in
* flight. It deliberately claims nothing about other realms, which are handled
* by native blocked ordering and explicit recovery.
*/
const PENDING_DELETIONS = new WeakMap<object, Set<string>>();
function pendingDeletionsFor(factory: unknown): Set<string> {
const key = (factory ?? PENDING_DELETIONS) as object;
let pending = PENDING_DELETIONS.get(key);
if (!pending) {
pending = new Set<string>();
PENDING_DELETIONS.set(key, pending);
}
return pending;
}
export type IndexedDbUploadCheckpointScope = Readonly<{ export type IndexedDbUploadCheckpointScope = Readonly<{
authorityToken: string; authorityToken: string;
namespaceToken: string; namespaceToken: string;
@@ -93,6 +112,14 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
? factory.deleteDatabase.bind(factory) ? factory.deleteDatabase.bind(factory)
: undefined; : undefined;
const databaseName = uploadCheckpointDatabaseName(scope); const databaseName = uploadCheckpointDatabaseName(scope);
const pendingDeletions = pendingDeletionsFor(factory);
if (pendingDeletions.has(databaseName)) {
// BT-UP-03. A deletion dispatched by this realm has not settled, so a new
// store over the same database would race an unknown native effect.
throw new TypeError(
"Upload checkpoint partition has an unresolved pending deletion.",
);
}
const expectedBinding: ScopeBinding = Object.freeze({ const expectedBinding: ScopeBinding = Object.freeze({
key: GOVERNANCE_KEY, key: GOVERNANCE_KEY,
schemaVersion: 1, schemaVersion: 1,
@@ -376,7 +403,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
async deletePartition( async deletePartition(
signal?: AbortSignal, signal?: AbortSignal,
): Promise< ): Promise<
BrowserDataResult<Readonly<{ state: "DELETED" }>> BrowserDataResult<PartitionDeleteOutcome>
> { > {
if (signal?.aborted) { if (signal?.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
@@ -397,46 +424,64 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
} catch (error) { } catch (error) {
return mapBrowserDataException(error, "UPLOAD_RECONCILE"); return mapBrowserDataException(error, "UPLOAD_RECONCILE");
} }
return await new Promise< // BT-UP-03. Once dispatched the deletion may still commit after this
BrowserDataResult<Readonly<{ state: "DELETED" }>> // call returns, so the pending registration is installed before the
>((resolve) => { // promise settles and is only released by the real native completion.
let settled = false; pendingDeletions.add(databaseName);
let blockedTimer: ReturnType<typeof setTimeout> | undefined; return await new Promise<BrowserDataResult<PartitionDeleteOutcome>>(
const finish = ( (resolve) => {
result: BrowserDataResult<Readonly<{ state: "DELETED" }>>, let settled = false;
) => { let blockedTimer: ReturnType<typeof setTimeout> | undefined;
if (settled) return; const finish = (
settled = true; result: BrowserDataResult<PartitionDeleteOutcome>,
if (blockedTimer) clearTimeout(blockedTimer); ) => {
resolve(result); if (settled) return;
}; settled = true;
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal is if (blockedTimer) clearTimeout(blockedTimer);
// intentionally observed only before dispatch so the adapter never resolve(result);
// reports ABORTED while deletion may still commit. };
request.onblocked = () => { const releasePending = () => {
blockedTimer = setTimeout(() => { pendingDeletions.delete(databaseName);
};
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal
// is intentionally observed only before dispatch so the adapter never
// reports ABORTED while deletion may still commit.
request.onblocked = () => {
blockedTimer = setTimeout(() => {
// Not NOT_APPLIED: the request is still live in the browser.
finish(
browserDataSuccess(
Object.freeze({
state: "PENDING" as const,
effect: "UNKNOWN" as const,
reason: "BLOCKED_DEADLINE" as const,
}),
),
);
}, blockedTimeoutMs);
};
request.onerror = () => {
releasePending();
finish( finish(
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", { mapBrowserDataException(
retryable: true, request.error,
recovery: "RELOAD_OTHER_CONTEXTS", "UPLOAD_RECONCILE",
}), ),
); );
}, blockedTimeoutMs); };
}; request.onsuccess = () => {
request.onerror = () => releasePending();
finish( finish(
mapBrowserDataException( browserDataSuccess(
request.error, Object.freeze({
"UPLOAD_RECONCILE", state: "DELETED" as const,
), effect: "APPLIED" as const,
); }),
request.onsuccess = () => ),
finish( );
browserDataSuccess( };
Object.freeze({ state: "DELETED" as const }), },
), );
);
});
}, },
}; };
const admin = Object.freeze(adminValue); const admin = Object.freeze(adminValue);
@@ -43,6 +43,15 @@ export function createPresignedUploadPartExecutor(
recovery: "RESUME", recovery: "RESUME",
}); });
} }
// BT-UP-04. A NaN or negative clock silently bypasses every expiry
// comparison, and an infinite one misreports a dependency failure as a
// capability policy failure. Both are dependency failures.
if (!Number.isSafeInteger(nowEpochMs) || nowEpochMs < 0) {
return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", {
retryable: true,
recovery: "RESUME",
});
}
if ( if (
!capability || !capability ||
capability.method !== "PUT" || capability.method !== "PUT" ||
@@ -0,0 +1,190 @@
/**
* BT-X-01. Shared abort and deadline mechanics.
*
* Several adapters independently reimplemented "race a promise against a
* caller signal and a deadline, then clean up listeners and timers". Only the
* mechanics are shared here; every subsystem keeps its own result taxonomy and
* recovery vocabulary, so this module deliberately imports none of them and is
* not a generic middleware layer.
*/
export type AbortTerminalReason = "CALLER_ABORT" | "DEADLINE" | "CLOSED";
export type AbortRace<Value> =
| Readonly<{ kind: "VALUE"; value: Value }>
| Readonly<{ kind: "TERMINAL"; terminal: AbortTerminalReason }>;
export type AbortableOperation = Readonly<{
/** The composed signal: caller abort, deadline and close all feed it. */
readonly signal: AbortSignal;
/**
* The first terminal owner, or `null` while the operation is still live.
* This is a live accessor, not a snapshot.
*/
terminal(): AbortTerminalReason | null;
/**
* Resolves with the operation's value, or with the first terminal owner.
* A terminal race never returns a bare value.
*/
race<Value>(operation: Promise<Value>): Promise<AbortRace<Value>>;
/**
* Idempotent. Removes listeners, clears the deadline timer and marks the
* operation `CLOSED` if nothing terminal happened first.
*/
close(): void;
}>;
export type AbortableOperationInput = Readonly<{
signal?: AbortSignal;
timeoutMs?: number;
/** Scheduler seam; a throwing scheduler must not leak a listener. */
setTimer?: (callback: () => void, delayMs: number) => unknown;
clearTimer?: (handle: unknown) => void;
}>;
export function createAbortableOperation(
input: AbortableOperationInput = {},
): AbortableOperation {
const controller = new AbortController();
const setTimer =
input.setTimer ??
((callback: () => void, delayMs: number) => setTimeout(callback, delayMs));
const clearTimer =
input.clearTimer ??
((handle: unknown) => {
clearTimeout(handle as ReturnType<typeof setTimeout>);
});
let terminalReason: AbortTerminalReason | null = null;
let disposed = false;
let timer: unknown;
/** First terminal owner wins; later owners never overwrite it. */
const settle = (reason: AbortTerminalReason) => {
terminalReason ??= reason;
if (!controller.signal.aborted) controller.abort();
};
const onCallerAbort = () => {
settle("CALLER_ABORT");
dispose();
};
function dispose(): void {
if (disposed) return;
disposed = true;
try {
input.signal?.removeEventListener("abort", onCallerAbort);
} catch {
// A hostile signal facade cannot block cleanup of the rest.
}
if (timer !== undefined) {
try {
clearTimer(timer);
} catch {
// A throwing scheduler cannot leave the operation un-disposed.
}
timer = undefined;
}
}
if (input.signal?.aborted) {
settle("CALLER_ABORT");
disposed = true;
} else if (input.signal) {
input.signal.addEventListener("abort", onCallerAbort, { once: true });
}
if (
terminalReason === null &&
input.timeoutMs !== undefined &&
Number.isFinite(input.timeoutMs) &&
input.timeoutMs >= 0
) {
try {
timer = setTimer(() => {
settle("DEADLINE");
dispose();
}, input.timeoutMs);
} catch {
// A scheduler that throws leaves no timer behind; the operation stays
// bounded only by the caller signal.
timer = undefined;
dispose();
}
}
return Object.freeze({
signal: controller.signal,
terminal: () => terminalReason,
async race<Value>(operation: Promise<Value>): Promise<AbortRace<Value>> {
// Observe a late rejection so an abandoned operation cannot surface as an
// unhandled rejection.
operation.catch(() => {});
if (terminalReason !== null) {
return Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason,
});
}
const raced = await Promise.race([
operation.then(
(value) => Object.freeze({ kind: "VALUE" as const, value }),
() =>
Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason ?? ("CLOSED" as const),
}),
),
new Promise<AbortRace<Value>>((resolve) => {
if (controller.signal.aborted) {
resolve(
Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason ?? ("CLOSED" as const),
}),
);
return;
}
controller.signal.addEventListener(
"abort",
() =>
resolve(
Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason ?? ("CLOSED" as const),
}),
),
{ once: true },
);
}),
]);
// A value that arrives after a terminal owner is not admitted.
return terminalReason !== null && raced.kind === "VALUE"
? Object.freeze({
kind: "TERMINAL" as const,
terminal: terminalReason,
})
: raced;
},
close() {
settle("CLOSED");
dispose();
},
});
}
/**
* Compensates a native handle that arrives after the operation ended. The
* compensation itself is best effort and can never change the already selected
* outcome.
*/
export function compensateLateHandle(
handle: Promise<Readonly<{ body?: { cancel(): Promise<void> } | null }> | null>,
): void {
void handle
.then(async (value) => {
await value?.body?.cancel();
})
.catch(() => undefined);
}
@@ -113,6 +113,13 @@ export type PresignedDownloadByteSource = FileByteSource &
byteLength: number; byteLength: number;
capability: PresignedDownloadCapability; capability: PresignedDownloadCapability;
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION"; integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION";
/**
* BT-PRE-01. Discards the lease. Before the first `stream()` this performs
* no network I/O at all; during streaming it cancels the body and releases
* the timer and listeners exactly once. Every consumer must call it in a
* `finally`, including on a pre-stream failure.
*/
close(): void;
}>; }>;
export interface PresignedDownloadSourcePort { export interface PresignedDownloadSourcePort {
@@ -40,6 +40,14 @@ export type UploadPartReceipt = UploadPartDescriptor &
receiptToken: string; receiptToken: string;
}>; }>;
export type PartitionDeleteOutcome =
| Readonly<{ state: "DELETED"; effect: "APPLIED" }>
| Readonly<{
state: "PENDING";
effect: "UNKNOWN";
reason: "BLOCKED_DEADLINE";
}>;
export interface UploadRangeReader { export interface UploadRangeReader {
readonly byteLength: number; readonly byteLength: number;
readRange(input: Readonly<{ readRange(input: Readonly<{
@@ -273,7 +281,13 @@ export interface ResumableUploadCheckpointAdmin {
* Account/logout lifecycle operation for this already-bound opaque partition. * Account/logout lifecycle operation for this already-bound opaque partition.
* The adapter closes its connection before deletion and bounds blocked waits. * The adapter closes its connection before deletion and bounds blocked waits.
*/ */
/**
* BT-UP-03. An IndexedDB `deleteDatabase()` request cannot be cancelled once
* dispatched, so a blocked deadline is not evidence that nothing happened.
* `PENDING` reports the effect honestly as `UNKNOWN`; only pre-dispatch
* problems are ordinary failures.
*/
deletePartition( deletePartition(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<BrowserDataResult<Readonly<{ state: "DELETED" }>>>; ): Promise<BrowserDataResult<PartitionDeleteOutcome>>;
} }
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import {
compensateLateHandle,
createAbortableOperation,
} from "../../src/adapters/platform/abortable-operation.ts";
/**
* BT-X-01 golden tests. The utility owns abort mechanics only: it must never
* import or imply a subsystem result taxonomy.
*/
describe("shared abortable operation mechanics", () => {
it("records the first terminal owner and never overwrites it", async () => {
const caller = new AbortController();
const timers: Array<() => void> = [];
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: (callback) => {
timers.push(callback);
return timers.length;
},
clearTimer: () => {},
});
expect(operation.terminal()).toBeNull();
caller.abort();
expect(operation.terminal()).toBe("CALLER_ABORT");
// A later deadline or close cannot rewrite the owner.
timers.forEach((callback) => callback());
operation.close();
expect(operation.terminal()).toBe("CALLER_ABORT");
});
it("reports a deadline owner and aborts the composed signal", async () => {
const timers: Array<() => void> = [];
const operation = createAbortableOperation({
timeoutMs: 5,
setTimer: (callback) => {
timers.push(callback);
return timers.length;
},
clearTimer: () => {},
});
timers[0]?.();
expect(operation.terminal()).toBe("DEADLINE");
expect(operation.signal.aborted).toBe(true);
});
it("race returns a terminal owner instead of a bare value", async () => {
const caller = new AbortController();
const operation = createAbortableOperation({ signal: caller.signal });
let release: ((value: string) => void) | undefined;
const pending = new Promise<string>((resolve) => {
release = resolve;
});
const raced = operation.race(pending);
caller.abort();
await expect(raced).resolves.toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
// The late value is observed and discarded.
release?.("late");
await expect(operation.race(pending)).resolves.toEqual({
kind: "TERMINAL",
terminal: "CALLER_ABORT",
});
});
it("race returns the value when nothing terminal happened", async () => {
const operation = createAbortableOperation();
await expect(operation.race(Promise.resolve(7))).resolves.toEqual({
kind: "VALUE",
value: 7,
});
operation.close();
expect(operation.terminal()).toBe("CLOSED");
});
it("observes a late rejection instead of leaking it", async () => {
const operation = createAbortableOperation();
const rejected = Promise.reject(new Error("late"));
await expect(operation.race(rejected)).resolves.toEqual({
kind: "TERMINAL",
terminal: "CLOSED",
});
});
it("close is idempotent and releases listeners and timers exactly once", () => {
const caller = new AbortController();
const remove = vi.spyOn(caller.signal, "removeEventListener");
const clearTimer = vi.fn();
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: () => "handle",
clearTimer,
});
operation.close();
operation.close();
operation.close();
expect(remove).toHaveBeenCalledTimes(1);
expect(clearTimer).toHaveBeenCalledTimes(1);
});
it("survives a throwing scheduler without leaking a listener", () => {
const caller = new AbortController();
const remove = vi.spyOn(caller.signal, "removeEventListener");
const operation = createAbortableOperation({
signal: caller.signal,
timeoutMs: 10,
setTimer: () => {
throw new TypeError("scheduler exploded");
},
clearTimer: () => {},
});
expect(operation.terminal()).toBeNull();
expect(remove).toHaveBeenCalledTimes(1);
});
it("compensates a late native handle without changing the outcome", async () => {
const cancel = vi.fn(async () => {});
compensateLateHandle(Promise.resolve({ body: { cancel } }));
await Promise.resolve();
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
// A rejecting handle is swallowed.
compensateLateHandle(Promise.reject(new Error("late")));
await Promise.resolve();
});
});
+4
View File
@@ -1479,6 +1479,10 @@ describe("browser image probe", () => {
close: vi.fn(), close: vi.fn(),
})); }));
for (const cacheControl of [ for (const cacheControl of [
// BT-IMG-02. Unmatched quotes must not be unwrapped into a bare number.
'public, max-age="31536000, immutable',
'public, max-age=31536000", immutable',
'public, max-age="31536000\\", immutable',
"public, public, max-age=31536000, immutable", "public, public, max-age=31536000, immutable",
"public, max-age=31536000, s-maxage=60, immutable", "public, max-age=31536000, s-maxage=60, immutable",
"public, max-age=31536000, immutable, must-revalidate", "public, max-age=31536000, immutable, must-revalidate",
+132 -21
View File
@@ -602,13 +602,15 @@ describe("presigned transfer", () => {
}); });
expect(issued.ok).toBe(true); expect(issued.ok).toBe(true);
if (!issued.ok) return; if (!issued.ok) return;
expect( await expect(
await harness.executor.downloadSources.open({ firstStreamResult(
resourceId: "resource-1", await harness.executor.downloadSources.open({
capability: issued.value, resourceId: "resource-1",
signal: new AbortController().signal, capability: issued.value,
}), signal: new AbortController().signal,
).toMatchObject({ }),
),
).resolves.toMatchObject({
ok: false, ok: false,
error: { code: "POLICY_REJECTED" }, error: { code: "POLICY_REJECTED" },
}); });
@@ -621,13 +623,15 @@ describe("presigned transfer", () => {
}); });
expect(issued.ok).toBe(true); expect(issued.ok).toBe(true);
if (!issued.ok) return; if (!issued.ok) return;
expect( await expect(
await harness.executor.downloadSources.open({ firstStreamResult(
resourceId: "resource-1", await harness.executor.downloadSources.open({
capability: issued.value, resourceId: "resource-1",
signal: new AbortController().signal, capability: issued.value,
}), signal: new AbortController().signal,
).toMatchObject({ }),
),
).resolves.toMatchObject({
ok: false, ok: false,
error: { code: "POLICY_REJECTED" }, error: { code: "POLICY_REJECTED" },
}); });
@@ -640,18 +644,101 @@ describe("presigned transfer", () => {
}); });
expect(issued.ok).toBe(true); expect(issued.ok).toBe(true);
if (!issued.ok) return; if (!issued.ok) return;
expect( await expect(
await harness.executor.downloadSources.open({ firstStreamResult(
resourceId: "resource-1", await harness.executor.downloadSources.open({
capability: issued.value, resourceId: "resource-1",
signal: new AbortController().signal, capability: issued.value,
}), signal: new AbortController().signal,
).toMatchObject({ }),
),
).resolves.toMatchObject({
ok: false, ok: false,
error: { code: "INTEGRITY_FAILED" }, error: { code: "INTEGRITY_FAILED" },
}); });
}); });
it("does not fetch a presigned download until stream consumption", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const responsePayload = downloadCapabilityPayload(bytes);
let downloadFetches = 0;
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(responsePayload);
}
downloadFetches += 1;
return downloadResponse(bytes.slice().buffer, responsePayload);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const signal = new AbortController().signal;
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
// BT-PRE-01. open() performs no network I/O.
expect(downloadFetches).toBe(0);
for await (const chunk of opened.value.stream(signal)) {
expect(chunk.ok).toBe(true);
}
expect(downloadFetches).toBe(1);
opened.value.close();
});
it("closes an unused download source without network I/O", async () => {
const bytes = new Uint8Array([1, 2, 3]);
const responsePayload = downloadCapabilityPayload(bytes);
let downloadFetches = 0;
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === CONTROL_ENDPOINT) {
return jsonResponse(responsePayload);
}
downloadFetches += 1;
return downloadResponse(bytes.slice().buffer, responsePayload);
}) as unknown as typeof fetch;
const { provider, executor } = createHarness({ fetcher });
const signal = new AbortController().signal;
const issued = await provider.issueDownload({
resourceId: "resource-1",
signal,
});
expect(issued.ok).toBe(true);
if (!issued.ok) return;
const opened = await executor.downloadSources.open({
resourceId: "resource-1",
capability: issued.value,
signal,
});
expect(opened.ok).toBe(true);
if (!opened.ok) return;
opened.value.close();
// close() is idempotent and never starts the transfer.
opened.value.close();
expect(downloadFetches).toBe(0);
// A stream after close is one terminal conflict, still without fetching.
const results = [];
for await (const chunk of opened.value.stream(signal)) {
results.push(chunk);
}
expect(results).toMatchObject([
{ ok: false, error: { code: "CONFLICT" } },
]);
expect(downloadFetches).toBe(0);
});
it.each([ it.each([
{ {
name: "truncation", name: "truncation",
@@ -1633,3 +1720,27 @@ describe("presigned transfer", () => {
expect(written).toEqual([...bytes]); expect(written).toEqual([...bytes]);
}); });
}); });
/**
* BT-PRE-01. The download lease is lazy, so a response-shape rejection is
* observed on first consumption rather than at `open()`.
*/
async function firstStreamResult(
opened: Awaited<
ReturnType<
ReturnType<typeof createHarness>["executor"]["downloadSources"]["open"]
>
>,
): Promise<unknown> {
if (!opened.ok) return opened;
try {
for await (const chunk of opened.value.stream(
new AbortController().signal,
)) {
if (!chunk.ok) return chunk;
}
return { ok: true };
} finally {
opened.value.close();
}
}
+37 -10
View File
@@ -183,7 +183,7 @@ describe("IndexedDB resumable upload checkpoint", () => {
).toMatchObject({ ok: true }); ).toMatchObject({ ok: true });
expect(await runtime.admin.deletePartition()).toEqual({ expect(await runtime.admin.deletePartition()).toEqual({
ok: true, ok: true,
value: { state: "DELETED" }, value: { state: "DELETED", effect: "APPLIED" },
}); });
expect(await runtime.store.read("upload_key_01")).toMatchObject({ expect(await runtime.store.read("upload_key_01")).toMatchObject({
ok: false, ok: false,
@@ -191,21 +191,48 @@ describe("IndexedDB resumable upload checkpoint", () => {
}); });
}); });
it("bounds a partition deletion blocked by another browser context", async () => { it("returns PENDING UNKNOWN when deleteDatabase is still blocked", async () => {
const memory = new MemoryIndexedDbFactory(); const memory = new MemoryIndexedDbFactory();
const factory = deletingFactory(memory, "BLOCKED");
const runtime = createIndexedDbResumableUploadCheckpointRuntime({ const runtime = createIndexedDbResumableUploadCheckpointRuntime({
scope, scope,
factory: deletingFactory(memory, "BLOCKED"), factory,
blockedTimeoutMs: 1,
});
// BT-UP-03. The native request is still live, so the deadline is not
// evidence that nothing happened.
expect(await runtime.admin.deletePartition()).toEqual({
ok: true,
value: {
state: "PENDING",
effect: "UNKNOWN",
reason: "BLOCKED_DEADLINE",
},
});
});
it("keeps the checkpoint store closed until a pending delete is resolved externally", async () => {
const memory = new MemoryIndexedDbFactory();
const factory = deletingFactory(memory, "BLOCKED");
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
scope,
factory,
blockedTimeoutMs: 1, blockedTimeoutMs: 1,
}); });
expect(await runtime.admin.deletePartition()).toMatchObject({ expect(await runtime.admin.deletePartition()).toMatchObject({
ok: false, ok: true,
error: { value: { state: "PENDING" },
code: "BLOCKED",
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
},
}); });
// A second runtime over the same realm and database would race an unknown
// native effect.
expect(() =>
createIndexedDbResumableUploadCheckpointRuntime({
scope,
factory,
blockedTimeoutMs: 1,
}),
).toThrow(TypeError);
}); });
it("never reports a false abort after irreversible deleteDatabase dispatch", async () => { it("never reports a false abort after irreversible deleteDatabase dispatch", async () => {
@@ -219,7 +246,7 @@ describe("IndexedDB resumable upload checkpoint", () => {
controller.abort(); controller.abort();
expect(await deletion).toEqual({ expect(await deletion).toEqual({
ok: true, ok: true,
value: { state: "DELETED" }, value: { state: "DELETED", effect: "APPLIED" },
}); });
const preAborted = new AbortController(); const preAborted = new AbortController();