diff --git a/scripts/check-adapter-inventory.ts b/scripts/check-adapter-inventory.ts index 0091d1c..331f20b 100644 --- a/scripts/check-adapter-inventory.ts +++ b/scripts/check-adapter-inventory.ts @@ -133,7 +133,7 @@ async function main(): Promise { // // 이 목록은 재검토가 이름으로 지목한 네 파일만 덮는다. 같은 두 그룹의 // `image-cdn-runtime.ts`와 `resumable-upload-runtime.ts`는 여기 없고 지금도 - // 자기 abort 사본을 들고 있다. 어댑터 전체로는 24개 파일이 그렇다. 그 전수 + // 자기 abort 사본을 들고 있다. 어댑터 전체로는 23개 파일이 그렇다. 그 전수 // 이행은 abort 의미론을 바꾸는 별도 작업이라 이 목록으로 강제하지 않고, // 아래 래칫이 개수가 늘어나는 것만 막는다. const REQUIRED_ABORT_CONSUMERS: readonly string[] = [ @@ -190,12 +190,15 @@ async function main(): Promise { // 손수 짠 abort 배선은 줄어들기만 해야 한다. // - // 커널 `platform/abortable-operation.ts`가 있는데도 어댑터 24개 파일이 + // 커널 `platform/abortable-operation.ts`가 있는데도 어댑터 23개 파일이 // `addEventListener("abort")`로 같은 race/cleanup을 각자 짠다. 그 전수 이행은 // 동작이 바뀌는 큰 작업이라 한 번에 하지 않는다. 대신 개수를 여기 고정해 // 되돌아가지 못하게 한다. 이행으로 숫자가 내려가면 이 상수도 같이 내린다. // `platform/`은 커널 자신이므로 세지 않는다. - const HAND_ROLLED_ABORT_CEILING = 24; + // + // 24 → 23: `browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts`가 + // IndexedDB 커널로 옮겨가면서 자기 abort 리스너를 지웠다. + const HAND_ROLLED_ABORT_CEILING = 23; const handRolledScan = spawnSync( "git", ["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"], diff --git a/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts b/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts index ee95180..1861697 100644 --- a/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts +++ b/src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts @@ -4,12 +4,26 @@ import type { ResumableUploadCheckpointStore, PartitionDeleteOutcome, } from "../../../application/ports/browser-transfer/resumable-upload.ts"; -import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts"; +import type { + BrowserDataFailure, + BrowserDataFailureCode, + BrowserDataRecovery, + BrowserDataResult, +} from "../../../application/ports/browser-file-storage/shared.ts"; +import { isBrowserDataFailureCode } from "../../../application/ports/browser-file-storage/shared.ts"; import { browserDataFailure, browserDataSuccess, mapBrowserDataException, } from "../../browser-file-storage/result.ts"; +import { snapshotAbortTimers } from "../../platform/abortable-operation.ts"; +import { + createIndexedDbConnection, + deleteIndexedDbDatabase, + openIndexedDbDatabase, + type IndexedDbTranslate, +} from "../../platform/indexeddb-connection.ts"; +import { runIndexedDbTransaction } from "../../platform/indexeddb-transaction.ts"; import { isResumableUploadCheckpoint, SAFE_OPAQUE_ID, @@ -20,6 +34,7 @@ const DATABASE_VERSION = 1; const CHECKPOINT_STORE = "checkpoints"; const GOVERNANCE_STORE = "governance"; const GOVERNANCE_KEY = "scope-binding"; +const OPERATION = "UPLOAD_RECONCILE"; const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000; /** @@ -65,10 +80,94 @@ type ScopeBinding = Readonly<{ partitionToken: string; }>; -type OpenFactory = ( - name: string, - version?: number, -) => IDBOpenDBRequest; +/** + * `browserDataFailure` and `mapBrowserDataException` build a `Result`, while the + * kernel's `translate` contract wants the failure on its own. Both only ever + * build the failure arm, so the branch below is a narrowing, not a claim. + */ +function failureOf(result: BrowserDataResult): BrowserDataFailure { + if (result.ok) { + throw new TypeError("A browser data failure was expected."); + } + return result.error; +} + +function checkpointFailure( + code: BrowserDataFailureCode, + options: Readonly<{ + retryable?: boolean; + recovery?: BrowserDataRecovery; + }> = {}, +): BrowserDataFailure { + return failureOf(browserDataFailure(code, OPERATION, options)); +} + +function mappedFailure(error: unknown): BrowserDataFailure { + return failureOf(mapBrowserDataException(error, OPERATION)); +} + +/** + * The kernel keeps an admission's `detail` opaque, so a rejected scope binding + * carries its own failure through it and the guard hands that failure straight + * back. Any other shape would mean a second taxonomy grew beside this one. + */ +function admissionFailure(detail: unknown): BrowserDataFailure | null { + if (!detail || typeof detail !== "object") return null; + const record = detail as Record; + return isBrowserDataFailureCode(record.code) && record.operation === OPERATION + ? (detail as BrowserDataFailure) + : null; +} + +/** + * BT-UP-03. The checkpoint store keeps its own failure table rather than the + * `mapIndexedDbException` the other three IndexedDB adapters share: the same + * native error is a different answer here (a `ConstraintError` recovers by + * REOPEN, a quota failure is retryable), and unifying the four tables is a + * separate change from moving the mechanics onto the kernel. + */ +const translate: IndexedDbTranslate = (cause) => { + switch (cause.kind) { + case "NATIVE_EXCEPTION": + return mappedFailure(cause.error); + case "NO_VALUE_PRODUCED": + // Three adapters call this UNAVAILABLE. Here a checkpoint transaction + // that committed without producing a value read a row it could not turn + // into a checkpoint, so reconciling is the only way forward; retrying + // would read the same row again. + return checkpointFailure("CORRUPT_DATA", { recovery: "RECONCILE" }); + case "BLOCKED": + case "BLOCKED_DEADLINE": + return checkpointFailure("BLOCKED", { + retryable: true, + recovery: "RESUME", + }); + case "CALLER_ABORT": + return checkpointFailure("ABORTED"); + case "CLOSED": + // A closed handle is not the caller aborting: the upload can resume once + // a new store is built over the partition. + return checkpointFailure("UNAVAILABLE", { recovery: "RESUME" }); + case "UPGRADE_REJECTED": + // The schema body's own throw is what the caller sees. A rejection with + // no detail can only come from a null `newVersion`, which an open request + // never reports. + return cause.detail === undefined + ? checkpointFailure("MIGRATION_FAILED", { recovery: "READ_ONLY" }) + : mappedFailure(cause.detail); + case "ADMISSION_REJECTED": + return ( + admissionFailure(cause.detail) ?? + checkpointFailure("POLICY_REJECTED", { recovery: "READ_ONLY" }) + ); + case "UNSUPPORTED": + return checkpointFailure("UNSUPPORTED", { recovery: "READ_ONLY" }); + default: { + const exhaustive: never = cause; + return exhaustive; + } + } +}; export function uploadCheckpointDatabaseName( scope: IndexedDbUploadCheckpointScope, @@ -104,13 +203,19 @@ export function createIndexedDbResumableUploadCheckpointRuntime( ) { throw new TypeError("Upload checkpoint blocked timeout is invalid."); } - const openFactory: OpenFactory | undefined = factory - ? factory.open.bind(factory) - : undefined; - const deleteFactory = - factory && typeof factory.deleteDatabase === "function" - ? factory.deleteDatabase.bind(factory) - : undefined; + // X-AUDIT-02. The timer callables are captured once, bound to their receiver, + // so replacing a global after composition cannot change how a blocked open or + // a blocked deletion already in flight is bounded. The kernel throws at + // construction if a positive deadline arrives without them. + const timers = snapshotAbortTimers({ + setTimeout: (callback: () => void, milliseconds: number) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle: ReturnType) => { + globalThis.clearTimeout(handle); + }, + }); + const canDelete = + factory !== undefined && typeof factory.deleteDatabase === "function"; const databaseName = uploadCheckpointDatabaseName(scope); const pendingDeletions = pendingDeletionsFor(factory); if (pendingDeletions.has(databaseName)) { @@ -125,118 +230,60 @@ export function createIndexedDbResumableUploadCheckpointRuntime( schemaVersion: 1, ...scope, }); - let database: IDBDatabase | null = null; - let opening: Promise> | null = null; - let closed = false; - async function open( - signal?: AbortSignal, - ): Promise> { - if (closed || !openFactory) { - return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", { - recovery: "RESUME", - }); - } - if (signal?.aborted) { - return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); - } - if (database) return browserDataSuccess(database); - if (!opening) { - opening = openAndBind().finally(() => { - opening = null; - }); - } - const result = await opening; - if (signal?.aborted) { - return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); - } - return result; - } - - async function openAndBind(): Promise> { - let request: IDBOpenDBRequest; - try { - request = openFactory!(databaseName, DATABASE_VERSION); - } catch (error) { - return mapBrowserDataException(error, "UPLOAD_RECONCILE"); - } - const opened = await new Promise>( - (resolve) => { - let settled = false; - let blockedTimer: ReturnType | undefined; - const finish = (result: BrowserDataResult) => { - if (settled) { - if (result.ok) result.value.close(); - return; - } - settled = true; - if (blockedTimer) clearTimeout(blockedTimer); - resolve(result); - }; - request.onupgradeneeded = () => { - try { - const db = request.result; - if (!db.objectStoreNames.contains(CHECKPOINT_STORE)) { - db.createObjectStore(CHECKPOINT_STORE, { - keyPath: "uploadKey", - }); - } - if (!db.objectStoreNames.contains(GOVERNANCE_STORE)) { - db.createObjectStore(GOVERNANCE_STORE, { - keyPath: "key", - }); - } - } catch (error) { - try { - request.transaction?.abort(); - } catch { - // The open request will surface the original closed failure. - } - finish(mapBrowserDataException(error, "UPLOAD_RECONCILE")); - } - }; - request.onblocked = () => { - blockedTimer = setTimeout(() => { - finish( - browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", { - retryable: true, - recovery: "RESUME", - }), - ); - }, blockedTimeoutMs); - }; - request.onerror = () => - finish( - mapBrowserDataException( - request.error, - "UPLOAD_RECONCILE", - ), - ); - request.onsuccess = () => finish(browserDataSuccess(request.result)); - }, - ); - if (!opened.ok) return opened; - if (closed) { - opened.value.close(); - return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", { - recovery: "RESUME", - }); - } - const bound = await bindScope(opened.value, expectedBinding); - if (!bound.ok) { - opened.value.close(); - return bound; - } - opened.value.onversionchange = () => { - opened.value.close(); - if (database === opened.value) database = null; - }; - opened.value.onclose = () => { - if (database === opened.value) database = null; - }; - database = opened.value; - return browserDataSuccess(opened.value); - } + /** + * The handle owns the cached connection, the single-flight open and the + * `versionchange`/`close` invalidation this file used to wire by hand. No + * `onVersionChange` callback is passed because closing the connection and + * dropping the cached handle — which the kernel already does — was this + * store's entire listener body; registering one inside `admit` instead would + * be overwritten when the handle adopts the connection. + */ + const connection = createIndexedDbConnection({ + translate, + open: (signal) => + factory === undefined + ? Promise.resolve( + browserDataFailure("UNAVAILABLE", OPERATION, { + recovery: "RESUME", + }), + ) + : openIndexedDbDatabase({ + factory, + databaseName, + version: DATABASE_VERSION, + translate, + signal, + blockedTimeoutMs, + timers, + upgrade: ({ database }) => { + try { + if (!database.objectStoreNames.contains(CHECKPOINT_STORE)) { + database.createObjectStore(CHECKPOINT_STORE, { + keyPath: "uploadKey", + }); + } + if (!database.objectStoreNames.contains(GOVERNANCE_STORE)) { + database.createObjectStore(GOVERNANCE_STORE, { + keyPath: "key", + }); + } + } catch (error) { + return { kind: "REJECTED", detail: error }; + } + return { kind: "APPLIED" }; + }, + // The binding runs before the caller ever sees the connection, so a + // partition bound to another scope can never serve a read. A + // rejected admission closes the connection inside the kernel. + admit: async (database) => { + const bound = await bindScope(database, expectedBinding); + return bound.ok + ? { kind: "ADMIT" } + : { kind: "REJECT", detail: bound.error }; + }, + }), + }); const storeValue: ResumableUploadCheckpointStore = { async read( @@ -246,12 +293,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime( BrowserDataResult > { if (!SAFE_UPLOAD_KEY.test(uploadKey)) { - return browserDataFailure( - "INVALID_INPUT", - "UPLOAD_RECONCILE", - ); + return browserDataFailure("INVALID_INPUT", OPERATION); } - const opened = await open(signal); + const opened = await connection.acquire(signal); if (!opened.ok) return opened; return await runCheckpointTransaction< ResumableUploadCheckpoint | null @@ -261,7 +305,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime( signal, (nativeStore, context) => { const request = nativeStore.get(uploadKey); - request.onerror = () => context.nativeFailure(request.error); + request.onerror = () => context.fail(mappedFailure(request.error)); request.onsuccess = () => { if (request.result === undefined) { context.succeed(null); @@ -269,11 +313,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime( } if (!isResumableUploadCheckpoint(request.result)) { context.fail( - browserDataFailure( - "CORRUPT_DATA", - "UPLOAD_RECONCILE", - { recovery: "RECONCILE" }, - ), + checkpointFailure("CORRUPT_DATA", { recovery: "RECONCILE" }), ); return; } @@ -294,10 +334,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime( try { checkpoint = snapshotCheckpoint(inputValue.checkpoint); } catch { - return browserDataFailure( - "INVALID_INPUT", - "UPLOAD_RECONCILE", - ); + return browserDataFailure("INVALID_INPUT", OPERATION); } const expectedRevision = inputValue.expectedRevision; if ( @@ -306,12 +343,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime( expectedRevision < 1)) || checkpoint.revision !== (expectedRevision ?? 0) + 1 ) { - return browserDataFailure( - "INVALID_INPUT", - "UPLOAD_RECONCILE", - ); + return browserDataFailure("INVALID_INPUT", OPERATION); } - const opened = await open(inputValue.signal); + const opened = await connection.acquire(inputValue.signal); if (!opened.ok) return opened; return await runCheckpointTransaction( opened.value, @@ -319,7 +353,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime( inputValue.signal, (nativeStore, context) => { const request = nativeStore.get(checkpoint.uploadKey); - request.onerror = () => context.nativeFailure(request.error); + request.onerror = () => context.fail(mappedFailure(request.error)); request.onsuccess = () => { const current = request.result; if ( @@ -329,16 +363,12 @@ export function createIndexedDbResumableUploadCheckpointRuntime( current.revision !== expectedRevision)) ) { context.fail( - browserDataFailure( - "CONFLICT", - "UPLOAD_RECONCILE", - { recovery: "RECONCILE" }, - ), + checkpointFailure("CONFLICT", { recovery: "RECONCILE" }), ); return; } const put = nativeStore.put(checkpoint); - put.onerror = () => context.nativeFailure(put.error); + put.onerror = () => context.fail(mappedFailure(put.error)); put.onsuccess = () => context.succeed(checkpoint); }; }, @@ -355,12 +385,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime( !Number.isSafeInteger(inputValue.expectedRevision) || inputValue.expectedRevision < 1 ) { - return browserDataFailure( - "INVALID_INPUT", - "UPLOAD_RECONCILE", - ); + return browserDataFailure("INVALID_INPUT", OPERATION); } - const opened = await open(inputValue.signal); + const opened = await connection.acquire(inputValue.signal); if (!opened.ok) return opened; return await runCheckpointTransaction( opened.value, @@ -368,24 +395,20 @@ export function createIndexedDbResumableUploadCheckpointRuntime( inputValue.signal, (nativeStore, context) => { const request = nativeStore.get(inputValue.uploadKey); - request.onerror = () => context.nativeFailure(request.error); + request.onerror = () => context.fail(mappedFailure(request.error)); request.onsuccess = () => { if ( !isResumableUploadCheckpoint(request.result) || request.result.revision !== inputValue.expectedRevision ) { context.fail( - browserDataFailure( - "CONFLICT", - "UPLOAD_RECONCILE", - { recovery: "RECONCILE" }, - ), + checkpointFailure("CONFLICT", { recovery: "RECONCILE" }), ); return; } const deletion = nativeStore.delete(inputValue.uploadKey); deletion.onerror = () => - context.nativeFailure(deletion.error); + context.fail(mappedFailure(deletion.error)); deletion.onsuccess = () => context.succeed(undefined); }; }, @@ -393,9 +416,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime( }, close() { - closed = true; - database?.close(); - database = null; + connection.close(); }, }; const store = Object.freeze(storeValue); @@ -405,82 +426,50 @@ export function createIndexedDbResumableUploadCheckpointRuntime( ): Promise< BrowserDataResult > { + // 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 — which is also why the + // kernel's delete takes no signal. if (signal?.aborted) { - return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); + return browserDataFailure("ABORTED", OPERATION); } - closed = true; - database?.close(); - database = null; - if (!deleteFactory) { - return browserDataFailure( - "UNSUPPORTED", - "UPLOAD_RECONCILE", - { recovery: "READ_ONLY" }, + connection.close(); + if (factory === undefined || !canDelete) { + return browserDataFailure("UNSUPPORTED", OPERATION, { + recovery: "READ_ONLY", + }); + } + // BT-UP-03. Once dispatched the deletion may still commit after this call + // returns, so the pending registration is installed before the promise + // settles and is only released by the real native completion — which is + // exactly what `onSettled` reports and a blocked deadline never does. + pendingDeletions.add(databaseName); + const deleted = await deleteIndexedDbDatabase({ + factory, + databaseName, + translate, + blockedTimeoutMs, + timers, + onSettled: () => { + pendingDeletions.delete(databaseName); + }, + }); + if (!deleted.ok) return deleted; + if (deleted.value.kind === "DELETED") { + return browserDataSuccess( + Object.freeze({ + state: "DELETED" as const, + effect: "APPLIED" as const, + }), ); } - let request: IDBOpenDBRequest; - try { - request = deleteFactory(databaseName); - } catch (error) { - return mapBrowserDataException(error, "UPLOAD_RECONCILE"); - } - // BT-UP-03. Once dispatched the deletion may still commit after this - // call returns, so the pending registration is installed before the - // promise settles and is only released by the real native completion. - pendingDeletions.add(databaseName); - return await new Promise>( - (resolve) => { - let settled = false; - let blockedTimer: ReturnType | undefined; - const finish = ( - result: BrowserDataResult, - ) => { - if (settled) return; - settled = true; - if (blockedTimer) clearTimeout(blockedTimer); - resolve(result); - }; - const releasePending = () => { - 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( - mapBrowserDataException( - request.error, - "UPLOAD_RECONCILE", - ), - ); - }; - request.onsuccess = () => { - releasePending(); - finish( - browserDataSuccess( - Object.freeze({ - state: "DELETED" as const, - effect: "APPLIED" as const, - }), - ), - ); - }; - }, + // Not NOT_APPLIED: the request is still live in the browser. + return browserDataSuccess( + Object.freeze({ + state: "PENDING" as const, + effect: "UNKNOWN" as const, + reason: "BLOCKED_DEADLINE" as const, + }), ); }, }; @@ -488,168 +477,79 @@ export function createIndexedDbResumableUploadCheckpointRuntime( return Object.freeze({ store, admin }); } -type TransactionContext = Readonly<{ - succeed(value: Value): void; - fail(result: BrowserDataResult): void; - nativeFailure(error: unknown): void; -}>; - +/** + * `durability` is deliberately `undefined` rather than `"default"`: that opens + * the transaction with no options bag at all, which is what checkpoint writes + * have always done. Passing a named value would quietly move them onto a + * different flush policy and slow every checkpoint write down. + * + * A request error goes through `fail`, which aborts, rather than through the + * kernel's `requestFailed`, which only records. `compareAndSwap` issues its put + * inside the get's success handler, so a transaction left running after a + * failed request is a transaction that can still commit half of a swap. + */ async function runCheckpointTransaction( database: IDBDatabase, - mode: IDBTransactionMode, + mode: "readonly" | "readwrite", signal: AbortSignal | undefined, execute: ( store: IDBObjectStore, - context: TransactionContext, + context: Readonly<{ + succeed(value: Value): void; + fail(failure: BrowserDataFailure): void; + }>, ) => void, ): Promise> { - if (signal?.aborted) { - return browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); - } - return await new Promise>((resolve) => { - let transaction: IDBTransaction; - try { - transaction = database.transaction(CHECKPOINT_STORE, mode); - } catch (error) { - resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE")); - return; - } - let value: Value | undefined; - let hasValue = false; - let failure: BrowserDataResult | null = null; - let settled = false; - const finish = (result: BrowserDataResult) => { - if (settled) return; - settled = true; - signal?.removeEventListener("abort", abort); - resolve(result); - }; - const abort = () => { - const previousFailure = failure; - failure = browserDataFailure("ABORTED", "UPLOAD_RECONCILE"); - try { - transaction.abort(); - } catch { - // The transaction may already be durably committed while its - // completion event is still queued. Wait for oncomplete/onabort so we - // never report ABORTED for a mutation that actually committed. - failure = previousFailure; - } - }; - signal?.addEventListener("abort", abort, { once: true }); - transaction.oncomplete = () => { - if (!hasValue) { - finish( - browserDataFailure("CORRUPT_DATA", "UPLOAD_RECONCILE", { - recovery: "RECONCILE", - }), - ); - return; - } - finish(browserDataSuccess(value as Value)); - }; - transaction.onerror = () => { - // onabort is the terminal transaction signal. - }; - transaction.onabort = () => - finish( - failure ?? - mapBrowserDataException( - transaction.error, - "UPLOAD_RECONCILE", - ), - ); - const context: TransactionContext = Object.freeze({ - succeed(next) { - if (failure) return; - value = next; - hasValue = true; - }, - fail(result) { - if (failure) return; - failure = result; - try { - transaction.abort(); - } catch { - finish(result); - } - }, - nativeFailure(error) { - if (failure) return; - failure = mapBrowserDataException( - error, - "UPLOAD_RECONCILE", - ); - try { - transaction.abort(); - } catch { - finish(failure); - } - }, - }); - try { + return await runIndexedDbTransaction({ + database, + stores: [CHECKPOINT_STORE], + mode, + translate, + signal, + durability: undefined, + queue: (transaction, context) => { execute(transaction.objectStore(CHECKPOINT_STORE), context); - } catch (error) { - context.nativeFailure(error); - } + }, }); } +/** + * No `signal`: the binding is part of opening the connection, and an open the + * caller gave up on is already ended by the kernel's own abort path. + */ async function bindScope( database: IDBDatabase, expected: ScopeBinding, ): Promise> { - return await new Promise>((resolve) => { - let transaction: IDBTransaction; - try { - transaction = database.transaction(GOVERNANCE_STORE, "readwrite"); - } catch (error) { - resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE")); - return; - } - let failure: BrowserDataResult | null = null; - transaction.onerror = () => { - // onabort owns terminal resolution. - }; - transaction.onabort = () => - resolve( - failure ?? - mapBrowserDataException( - transaction.error, - "UPLOAD_RECONCILE", - ), - ); - transaction.oncomplete = () => resolve(browserDataSuccess(undefined)); - const store = transaction.objectStore(GOVERNANCE_STORE); - const request = store.get(GOVERNANCE_KEY); - request.onerror = () => { - failure = mapBrowserDataException( - request.error, - "UPLOAD_RECONCILE", - ); - transaction.abort(); - }; - request.onsuccess = () => { - if (request.result === undefined) { - const add = store.add(expected); - add.onerror = () => { - failure = mapBrowserDataException( - add.error, - "UPLOAD_RECONCILE", + return await runIndexedDbTransaction({ + database, + stores: [GOVERNANCE_STORE], + mode: "readwrite", + translate, + durability: undefined, + queue: (transaction, context) => { + const store = transaction.objectStore(GOVERNANCE_STORE); + const request = store.get(GOVERNANCE_KEY); + request.onerror = () => context.fail(mappedFailure(request.error)); + request.onsuccess = () => { + if (request.result === undefined) { + const add = store.add(expected); + add.onerror = () => context.fail(mappedFailure(add.error)); + // The binding is the value: without this the committed transaction + // would report NO_VALUE_PRODUCED, which this store reads as + // CORRUPT_DATA. + add.onsuccess = () => context.succeed(undefined); + return; + } + if (!sameScopeBinding(request.result, expected)) { + context.fail( + checkpointFailure("POLICY_REJECTED", { recovery: "READ_ONLY" }), ); - transaction.abort(); - }; - return; - } - if (!sameScopeBinding(request.result, expected)) { - failure = browserDataFailure( - "POLICY_REJECTED", - "UPLOAD_RECONCILE", - { recovery: "READ_ONLY" }, - ); - transaction.abort(); - } - }; + return; + } + context.succeed(undefined); + }; + }, }); } diff --git a/tests/unit/resumable-upload-checkpoint.test.ts b/tests/unit/resumable-upload-checkpoint.test.ts index 1034a49..88aca0b 100644 --- a/tests/unit/resumable-upload-checkpoint.test.ts +++ b/tests/unit/resumable-upload-checkpoint.test.ts @@ -80,6 +80,155 @@ function deletingFactory( } as IDBFactory; } +/** + * A database the test drives by hand. The memory fake commits or aborts on its + * own, so the three places the checkpoint store deliberately disagrees with the + * other IndexedDB adapters — a commit that produced no value, a request error + * that must abort, and an `abort()` that throws — are only observable here. + * + * The scope-binding transaction is driven automatically because no test below + * is about it; only checkpoint transactions are handed to the test. + */ +type ScriptedRequest = { + result: unknown; + error: DOMException | null; + onsuccess: ((event: Event) => unknown) | null; + onerror: ((event: Event) => unknown) | null; +}; + +type ScriptedTransaction = { + abortCalls: number; + error: DOMException | null; + oncomplete: ((event: Event) => unknown) | null; + onerror: ((event: Event) => unknown) | null; + onabort: ((event: Event) => unknown) | null; + readonly requests: ScriptedRequest[]; + objectStore(name: string): IDBObjectStore; + abort(): void; + complete(): void; +}; + +function scriptedFactory( + options: Readonly<{ abortThrows?: boolean }> = {}, +): Readonly<{ + factory: IDBFactory; + /** One entry per `database.transaction(...)` call, holding its argument count. */ + transactionArguments: number[]; + checkpoints: ScriptedTransaction[]; +}> { + const transactionArguments: number[] = []; + const checkpoints: ScriptedTransaction[] = []; + + const scriptedRequest = (): ScriptedRequest => ({ + result: undefined, + error: null, + onsuccess: null, + onerror: null, + }); + + const makeTransaction = (): ScriptedTransaction => { + const requests: ScriptedRequest[] = []; + const transaction: ScriptedTransaction = { + abortCalls: 0, + error: null, + oncomplete: null, + onerror: null, + onabort: null, + requests, + objectStore: () => { + const queue = () => { + const request = scriptedRequest(); + requests.push(request); + return request as unknown as IDBRequest; + }; + return { + get: queue, + add: queue, + put: queue, + delete: queue, + } as unknown as IDBObjectStore; + }, + abort() { + transaction.abortCalls += 1; + if (options.abortThrows === true) { + throw new DOMException("Transaction is finished.", "InvalidStateError"); + } + transaction.error = new DOMException("Transaction aborted.", "AbortError"); + transaction.onerror?.(new Event("error")); + transaction.onabort?.(new Event("abort")); + }, + complete() { + transaction.oncomplete?.(new Event("complete")); + }, + }; + return transaction; + }; + + const database = { + close() {}, + transaction: (...args: unknown[]) => { + transactionArguments.push(args.length); + const requested = args[0]; + const names = + typeof requested === "string" + ? [requested] + : [...(requested as Iterable)]; + const transaction = makeTransaction(); + if (names.includes("governance")) { + queueMicrotask(() => { + transaction.requests[0]?.onsuccess?.(new Event("success")); + queueMicrotask(() => { + transaction.requests[1]?.onsuccess?.(new Event("success")); + queueMicrotask(() => { + transaction.complete(); + }); + }); + }); + } else { + checkpoints.push(transaction); + } + return transaction as unknown as IDBTransaction; + }, + } as unknown as IDBDatabase; + + const factory = { + open: () => { + const request = { + result: database, + error: null, + transaction: null, + onsuccess: null, + onerror: null, + onblocked: null, + onupgradeneeded: null, + } as unknown as IDBOpenDBRequest; + queueMicrotask(() => request.onsuccess?.(new Event("success"))); + return request; + }, + cmp: () => 0, + databases: async () => [], + } as unknown as IDBFactory; + + return { factory, transactionArguments, checkpoints }; +} + +async function flushTasks(): Promise { + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } +} + +async function openedCheckpointTransaction( + scripted: ReturnType, +): Promise { + await flushTasks(); + const transaction = scripted.checkpoints[0]; + if (!transaction) throw new Error("no checkpoint transaction was opened"); + return transaction; +} + describe("IndexedDB resumable upload checkpoint", () => { it("length-prefixes scope tuples so delimiter placement cannot collide", () => { const first = uploadCheckpointDatabaseName({ @@ -262,4 +411,112 @@ describe("IndexedDB resumable upload checkpoint", () => { error: { code: "ABORTED" }, }); }); + + // CP-1. Three IndexedDB adapters report a value-less commit as UNAVAILABLE; + // this one reports CORRUPT_DATA/RECONCILE because a checkpoint read that + // committed while producing nothing means the stored row could not be turned + // into a checkpoint, and the caller has to reconcile rather than retry. + it("reports a checkpoint commit that produced no value as CORRUPT_DATA", async () => { + const scripted = scriptedFactory(); + const store = createIndexedDbResumableUploadCheckpointStore({ + scope, + factory: scripted.factory, + }); + const pending = store.read("upload_key_01"); + const transaction = await openedCheckpointTransaction(scripted); + + transaction.complete(); + + expect(await pending).toMatchObject({ + ok: false, + error: { + code: "CORRUPT_DATA", + operation: "UPLOAD_RECONCILE", + recovery: "RECONCILE", + }, + }); + }); + + // CP-2. A third argument would be a `{durability}` options bag. An engine + // that has never seen the bag treats it differently from no bag at all, so + // checkpoint writes stay on whatever the engine defaults to. + it("opens checkpoint transactions without a durability options bag", async () => { + const scripted = scriptedFactory(); + const store = createIndexedDbResumableUploadCheckpointStore({ + scope, + factory: scripted.factory, + }); + const pending = store.read("upload_key_01"); + const transaction = await openedCheckpointTransaction(scripted); + transaction.complete(); + await pending; + + expect(scripted.transactionArguments).toEqual([2, 2]); + }); + + // CP-3. Recording the error and letting the transaction run on would let + // `compareAndSwap`'s put commit after its get had already failed. + it("aborts the checkpoint transaction as soon as a request fails", async () => { + const scripted = scriptedFactory(); + const store = createIndexedDbResumableUploadCheckpointStore({ + scope, + factory: scripted.factory, + }); + const pending = store.read("upload_key_01"); + const transaction = await openedCheckpointTransaction(scripted); + const read = transaction.requests[0]; + if (!read) throw new Error("the checkpoint read queued no request"); + + read.error = new DOMException("disk is unreadable", "NotReadableError"); + read.onerror?.(new Event("error")); + + expect(transaction.abortCalls).toBe(1); + expect(await pending).toMatchObject({ + ok: false, + error: { code: "NOT_READABLE", retryable: true, recovery: "REOPEN" }, + }); + }); + + // CP-4. The transaction can already be durably committed while its completion + // event is still queued, which is when `abort()` throws. Claiming the abort + // would report a committed checkpoint as ABORTED. + it("keeps a committed checkpoint when the abort call itself throws", async () => { + const scripted = scriptedFactory({ abortThrows: true }); + const store = createIndexedDbResumableUploadCheckpointStore({ + scope, + factory: scripted.factory, + }); + const controller = new AbortController(); + const stored = checkpoint(1); + const pending = store.read(stored.uploadKey, controller.signal); + const transaction = await openedCheckpointTransaction(scripted); + const read = transaction.requests[0]; + if (!read) throw new Error("the checkpoint read queued no request"); + + read.result = stored; + read.onsuccess?.(new Event("success")); + controller.abort(); + expect(transaction.abortCalls).toBe(1); + transaction.complete(); + + expect(await pending).toEqual({ ok: true, value: stored }); + }); + + // CP-5. The blocked deadline is the only bound on an open that another + // context is holding up, so it has to fire from the scheduler the store was + // built with rather than leave the read pending forever. + it("bounds a blocked open with the configured deadline", async () => { + const memory = new MemoryIndexedDbFactory(); + const store = createIndexedDbResumableUploadCheckpointStore({ + scope, + factory: memory.factory, + blockedTimeoutMs: 1, + }); + memory.blockNextOpen(); + + expect(await store.read("upload_key_01")).toMatchObject({ + ok: false, + error: { code: "BLOCKED", retryable: true, recovery: "RESUME" }, + }); + }); });