refactor: move the upload checkpoint store onto the IndexedDB kernel

712 -> 612줄. 연결 수명주기와 트랜잭션 상태기계가 커널로 갔고 CP의 손수
abort 리스너가 0이 됐다. 그래서 abort 래칫을 24에서 23으로 조인다.

보존한 동작 다섯 가지:
- 값 없는 완료는 CORRUPT_DATA/RECONCILE (translate에 NO_VALUE_PRODUCED를
  명시 매핑). 빠뜨리면 UNAVAILABLE로 바뀐다
- durability는 undefined를 명시적으로 넘긴다. 커널 기본이나 strict를 쓰면
  체크포인트 쓰기가 조용히 느려진다
- nativeFailure 6곳 전부 context.fail. requestFailed로 바꾸면 요청이
  실패했는데도 뒤 요청이 커밋된다
- abort()가 throw하면 caller-abort 주장을 철회한다 (커널이 이미 구현)
- blocked 타이머는 주입형 timers

사양이 빠뜨린 함정 하나를 추가로 막았다. bindScope는 succeed()에 해당하는
것이 없어 그대로 옮기면 정상 바인딩이 NO_VALUE_PRODUCED로 보고된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 19:24:09 +09:00
co-authored by Claude Opus 5
parent 35f3c9ce23
commit bb6080bb1c
3 changed files with 546 additions and 386 deletions
@@ -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<never>): 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<string, unknown>;
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<BrowserDataFailure> = (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<typeof globalThis.setTimeout>) => {
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<BrowserDataResult<IDBDatabase>> | null = null;
let closed = false;
async function open(
signal?: AbortSignal,
): Promise<BrowserDataResult<IDBDatabase>> {
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<BrowserDataResult<IDBDatabase>> {
let request: IDBOpenDBRequest;
try {
request = openFactory!(databaseName, DATABASE_VERSION);
} catch (error) {
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
}
const opened = await new Promise<BrowserDataResult<IDBDatabase>>(
(resolve) => {
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (result: BrowserDataResult<IDBDatabase>) => {
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<BrowserDataFailure>({
translate,
open: (signal) =>
factory === undefined
? Promise.resolve(
browserDataFailure("UNAVAILABLE", OPERATION, {
recovery: "RESUME",
}),
)
: openIndexedDbDatabase<BrowserDataFailure>({
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<ResumableUploadCheckpoint | null>
> {
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<ResumableUploadCheckpoint>(
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<void>(
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<PartitionDeleteOutcome>
> {
// 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<BrowserDataFailure>({
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<BrowserDataResult<PartitionDeleteOutcome>>(
(resolve) => {
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (
result: BrowserDataResult<PartitionDeleteOutcome>,
) => {
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<Value> = Readonly<{
succeed(value: Value): void;
fail(result: BrowserDataResult<never>): 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<Value>(
database: IDBDatabase,
mode: IDBTransactionMode,
mode: "readonly" | "readwrite",
signal: AbortSignal | undefined,
execute: (
store: IDBObjectStore,
context: TransactionContext<Value>,
context: Readonly<{
succeed(value: Value): void;
fail(failure: BrowserDataFailure): void;
}>,
) => void,
): Promise<BrowserDataResult<Value>> {
if (signal?.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
}
return await new Promise<BrowserDataResult<Value>>((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<never> | null = null;
let settled = false;
const finish = (result: BrowserDataResult<Value>) => {
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<Value> = 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<Value, BrowserDataFailure>({
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<BrowserDataResult<void>> {
return await new Promise<BrowserDataResult<void>>((resolve) => {
let transaction: IDBTransaction;
try {
transaction = database.transaction(GOVERNANCE_STORE, "readwrite");
} catch (error) {
resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE"));
return;
}
let failure: BrowserDataResult<never> | 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<void, BrowserDataFailure>({
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);
};
},
});
}