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:
co-authored by
Claude Opus 5
parent
35f3c9ce23
commit
bb6080bb1c
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user