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
@@ -133,7 +133,7 @@ async function main(): Promise<void> {
|
|||||||
//
|
//
|
||||||
// 이 목록은 재검토가 이름으로 지목한 네 파일만 덮는다. 같은 두 그룹의
|
// 이 목록은 재검토가 이름으로 지목한 네 파일만 덮는다. 같은 두 그룹의
|
||||||
// `image-cdn-runtime.ts`와 `resumable-upload-runtime.ts`는 여기 없고 지금도
|
// `image-cdn-runtime.ts`와 `resumable-upload-runtime.ts`는 여기 없고 지금도
|
||||||
// 자기 abort 사본을 들고 있다. 어댑터 전체로는 24개 파일이 그렇다. 그 전수
|
// 자기 abort 사본을 들고 있다. 어댑터 전체로는 23개 파일이 그렇다. 그 전수
|
||||||
// 이행은 abort 의미론을 바꾸는 별도 작업이라 이 목록으로 강제하지 않고,
|
// 이행은 abort 의미론을 바꾸는 별도 작업이라 이 목록으로 강제하지 않고,
|
||||||
// 아래 래칫이 개수가 늘어나는 것만 막는다.
|
// 아래 래칫이 개수가 늘어나는 것만 막는다.
|
||||||
const REQUIRED_ABORT_CONSUMERS: readonly string[] = [
|
const REQUIRED_ABORT_CONSUMERS: readonly string[] = [
|
||||||
@@ -190,12 +190,15 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// 손수 짠 abort 배선은 줄어들기만 해야 한다.
|
// 손수 짠 abort 배선은 줄어들기만 해야 한다.
|
||||||
//
|
//
|
||||||
// 커널 `platform/abortable-operation.ts`가 있는데도 어댑터 24개 파일이
|
// 커널 `platform/abortable-operation.ts`가 있는데도 어댑터 23개 파일이
|
||||||
// `addEventListener("abort")`로 같은 race/cleanup을 각자 짠다. 그 전수 이행은
|
// `addEventListener("abort")`로 같은 race/cleanup을 각자 짠다. 그 전수 이행은
|
||||||
// 동작이 바뀌는 큰 작업이라 한 번에 하지 않는다. 대신 개수를 여기 고정해
|
// 동작이 바뀌는 큰 작업이라 한 번에 하지 않는다. 대신 개수를 여기 고정해
|
||||||
// 되돌아가지 못하게 한다. 이행으로 숫자가 내려가면 이 상수도 같이 내린다.
|
// 되돌아가지 못하게 한다. 이행으로 숫자가 내려가면 이 상수도 같이 내린다.
|
||||||
// `platform/`은 커널 자신이므로 세지 않는다.
|
// `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(
|
const handRolledScan = spawnSync(
|
||||||
"git",
|
"git",
|
||||||
["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"],
|
["grep", "-l", 'addEventListener("abort"', "--", "src/adapters"],
|
||||||
|
|||||||
@@ -4,12 +4,26 @@ import type {
|
|||||||
ResumableUploadCheckpointStore,
|
ResumableUploadCheckpointStore,
|
||||||
PartitionDeleteOutcome,
|
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 {
|
||||||
|
BrowserDataFailure,
|
||||||
|
BrowserDataFailureCode,
|
||||||
|
BrowserDataRecovery,
|
||||||
|
BrowserDataResult,
|
||||||
|
} from "../../../application/ports/browser-file-storage/shared.ts";
|
||||||
|
import { isBrowserDataFailureCode } from "../../../application/ports/browser-file-storage/shared.ts";
|
||||||
import {
|
import {
|
||||||
browserDataFailure,
|
browserDataFailure,
|
||||||
browserDataSuccess,
|
browserDataSuccess,
|
||||||
mapBrowserDataException,
|
mapBrowserDataException,
|
||||||
} from "../../browser-file-storage/result.ts";
|
} 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 {
|
import {
|
||||||
isResumableUploadCheckpoint,
|
isResumableUploadCheckpoint,
|
||||||
SAFE_OPAQUE_ID,
|
SAFE_OPAQUE_ID,
|
||||||
@@ -20,6 +34,7 @@ const DATABASE_VERSION = 1;
|
|||||||
const CHECKPOINT_STORE = "checkpoints";
|
const CHECKPOINT_STORE = "checkpoints";
|
||||||
const GOVERNANCE_STORE = "governance";
|
const GOVERNANCE_STORE = "governance";
|
||||||
const GOVERNANCE_KEY = "scope-binding";
|
const GOVERNANCE_KEY = "scope-binding";
|
||||||
|
const OPERATION = "UPLOAD_RECONCILE";
|
||||||
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
|
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,10 +80,94 @@ type ScopeBinding = Readonly<{
|
|||||||
partitionToken: string;
|
partitionToken: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type OpenFactory = (
|
/**
|
||||||
name: string,
|
* `browserDataFailure` and `mapBrowserDataException` build a `Result`, while the
|
||||||
version?: number,
|
* kernel's `translate` contract wants the failure on its own. Both only ever
|
||||||
) => IDBOpenDBRequest;
|
* 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(
|
export function uploadCheckpointDatabaseName(
|
||||||
scope: IndexedDbUploadCheckpointScope,
|
scope: IndexedDbUploadCheckpointScope,
|
||||||
@@ -104,13 +203,19 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
) {
|
) {
|
||||||
throw new TypeError("Upload checkpoint blocked timeout is invalid.");
|
throw new TypeError("Upload checkpoint blocked timeout is invalid.");
|
||||||
}
|
}
|
||||||
const openFactory: OpenFactory | undefined = factory
|
// X-AUDIT-02. The timer callables are captured once, bound to their receiver,
|
||||||
? factory.open.bind(factory)
|
// so replacing a global after composition cannot change how a blocked open or
|
||||||
: undefined;
|
// a blocked deletion already in flight is bounded. The kernel throws at
|
||||||
const deleteFactory =
|
// construction if a positive deadline arrives without them.
|
||||||
factory && typeof factory.deleteDatabase === "function"
|
const timers = snapshotAbortTimers({
|
||||||
? factory.deleteDatabase.bind(factory)
|
setTimeout: (callback: () => void, milliseconds: number) =>
|
||||||
: undefined;
|
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 databaseName = uploadCheckpointDatabaseName(scope);
|
||||||
const pendingDeletions = pendingDeletionsFor(factory);
|
const pendingDeletions = pendingDeletionsFor(factory);
|
||||||
if (pendingDeletions.has(databaseName)) {
|
if (pendingDeletions.has(databaseName)) {
|
||||||
@@ -125,118 +230,60 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
...scope,
|
...scope,
|
||||||
});
|
});
|
||||||
let database: IDBDatabase | null = null;
|
|
||||||
let opening: Promise<BrowserDataResult<IDBDatabase>> | null = null;
|
|
||||||
let closed = false;
|
|
||||||
|
|
||||||
async function open(
|
/**
|
||||||
signal?: AbortSignal,
|
* The handle owns the cached connection, the single-flight open and the
|
||||||
): Promise<BrowserDataResult<IDBDatabase>> {
|
* `versionchange`/`close` invalidation this file used to wire by hand. No
|
||||||
if (closed || !openFactory) {
|
* `onVersionChange` callback is passed because closing the connection and
|
||||||
return browserDataFailure("UNAVAILABLE", "UPLOAD_RECONCILE", {
|
* dropping the cached handle — which the kernel already does — was this
|
||||||
recovery: "RESUME",
|
* store's entire listener body; registering one inside `admit` instead would
|
||||||
});
|
* be overwritten when the handle adopts the connection.
|
||||||
}
|
*/
|
||||||
if (signal?.aborted) {
|
const connection = createIndexedDbConnection<BrowserDataFailure>({
|
||||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
translate,
|
||||||
}
|
open: (signal) =>
|
||||||
if (database) return browserDataSuccess(database);
|
factory === undefined
|
||||||
if (!opening) {
|
? Promise.resolve(
|
||||||
opening = openAndBind().finally(() => {
|
browserDataFailure("UNAVAILABLE", OPERATION, {
|
||||||
opening = null;
|
recovery: "RESUME",
|
||||||
});
|
}),
|
||||||
}
|
)
|
||||||
const result = await opening;
|
: openIndexedDbDatabase<BrowserDataFailure>({
|
||||||
if (signal?.aborted) {
|
factory,
|
||||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
databaseName,
|
||||||
}
|
version: DATABASE_VERSION,
|
||||||
return result;
|
translate,
|
||||||
}
|
signal,
|
||||||
|
blockedTimeoutMs,
|
||||||
async function openAndBind(): Promise<BrowserDataResult<IDBDatabase>> {
|
timers,
|
||||||
let request: IDBOpenDBRequest;
|
upgrade: ({ database }) => {
|
||||||
try {
|
try {
|
||||||
request = openFactory!(databaseName, DATABASE_VERSION);
|
if (!database.objectStoreNames.contains(CHECKPOINT_STORE)) {
|
||||||
} catch (error) {
|
database.createObjectStore(CHECKPOINT_STORE, {
|
||||||
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
|
keyPath: "uploadKey",
|
||||||
}
|
});
|
||||||
const opened = await new Promise<BrowserDataResult<IDBDatabase>>(
|
}
|
||||||
(resolve) => {
|
if (!database.objectStoreNames.contains(GOVERNANCE_STORE)) {
|
||||||
let settled = false;
|
database.createObjectStore(GOVERNANCE_STORE, {
|
||||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
keyPath: "key",
|
||||||
const finish = (result: BrowserDataResult<IDBDatabase>) => {
|
});
|
||||||
if (settled) {
|
}
|
||||||
if (result.ok) result.value.close();
|
} catch (error) {
|
||||||
return;
|
return { kind: "REJECTED", detail: error };
|
||||||
}
|
}
|
||||||
settled = true;
|
return { kind: "APPLIED" };
|
||||||
if (blockedTimer) clearTimeout(blockedTimer);
|
},
|
||||||
resolve(result);
|
// The binding runs before the caller ever sees the connection, so a
|
||||||
};
|
// partition bound to another scope can never serve a read. A
|
||||||
request.onupgradeneeded = () => {
|
// rejected admission closes the connection inside the kernel.
|
||||||
try {
|
admit: async (database) => {
|
||||||
const db = request.result;
|
const bound = await bindScope(database, expectedBinding);
|
||||||
if (!db.objectStoreNames.contains(CHECKPOINT_STORE)) {
|
return bound.ok
|
||||||
db.createObjectStore(CHECKPOINT_STORE, {
|
? { kind: "ADMIT" }
|
||||||
keyPath: "uploadKey",
|
: { kind: "REJECT", detail: bound.error };
|
||||||
});
|
},
|
||||||
}
|
}),
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const storeValue: ResumableUploadCheckpointStore = {
|
const storeValue: ResumableUploadCheckpointStore = {
|
||||||
async read(
|
async read(
|
||||||
@@ -246,12 +293,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
BrowserDataResult<ResumableUploadCheckpoint | null>
|
BrowserDataResult<ResumableUploadCheckpoint | null>
|
||||||
> {
|
> {
|
||||||
if (!SAFE_UPLOAD_KEY.test(uploadKey)) {
|
if (!SAFE_UPLOAD_KEY.test(uploadKey)) {
|
||||||
return browserDataFailure(
|
return browserDataFailure("INVALID_INPUT", OPERATION);
|
||||||
"INVALID_INPUT",
|
|
||||||
"UPLOAD_RECONCILE",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const opened = await open(signal);
|
const opened = await connection.acquire(signal);
|
||||||
if (!opened.ok) return opened;
|
if (!opened.ok) return opened;
|
||||||
return await runCheckpointTransaction<
|
return await runCheckpointTransaction<
|
||||||
ResumableUploadCheckpoint | null
|
ResumableUploadCheckpoint | null
|
||||||
@@ -261,7 +305,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
signal,
|
signal,
|
||||||
(nativeStore, context) => {
|
(nativeStore, context) => {
|
||||||
const request = nativeStore.get(uploadKey);
|
const request = nativeStore.get(uploadKey);
|
||||||
request.onerror = () => context.nativeFailure(request.error);
|
request.onerror = () => context.fail(mappedFailure(request.error));
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
if (request.result === undefined) {
|
if (request.result === undefined) {
|
||||||
context.succeed(null);
|
context.succeed(null);
|
||||||
@@ -269,11 +313,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
}
|
}
|
||||||
if (!isResumableUploadCheckpoint(request.result)) {
|
if (!isResumableUploadCheckpoint(request.result)) {
|
||||||
context.fail(
|
context.fail(
|
||||||
browserDataFailure(
|
checkpointFailure("CORRUPT_DATA", { recovery: "RECONCILE" }),
|
||||||
"CORRUPT_DATA",
|
|
||||||
"UPLOAD_RECONCILE",
|
|
||||||
{ recovery: "RECONCILE" },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -294,10 +334,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
try {
|
try {
|
||||||
checkpoint = snapshotCheckpoint(inputValue.checkpoint);
|
checkpoint = snapshotCheckpoint(inputValue.checkpoint);
|
||||||
} catch {
|
} catch {
|
||||||
return browserDataFailure(
|
return browserDataFailure("INVALID_INPUT", OPERATION);
|
||||||
"INVALID_INPUT",
|
|
||||||
"UPLOAD_RECONCILE",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const expectedRevision = inputValue.expectedRevision;
|
const expectedRevision = inputValue.expectedRevision;
|
||||||
if (
|
if (
|
||||||
@@ -306,12 +343,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
expectedRevision < 1)) ||
|
expectedRevision < 1)) ||
|
||||||
checkpoint.revision !== (expectedRevision ?? 0) + 1
|
checkpoint.revision !== (expectedRevision ?? 0) + 1
|
||||||
) {
|
) {
|
||||||
return browserDataFailure(
|
return browserDataFailure("INVALID_INPUT", OPERATION);
|
||||||
"INVALID_INPUT",
|
|
||||||
"UPLOAD_RECONCILE",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const opened = await open(inputValue.signal);
|
const opened = await connection.acquire(inputValue.signal);
|
||||||
if (!opened.ok) return opened;
|
if (!opened.ok) return opened;
|
||||||
return await runCheckpointTransaction<ResumableUploadCheckpoint>(
|
return await runCheckpointTransaction<ResumableUploadCheckpoint>(
|
||||||
opened.value,
|
opened.value,
|
||||||
@@ -319,7 +353,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
inputValue.signal,
|
inputValue.signal,
|
||||||
(nativeStore, context) => {
|
(nativeStore, context) => {
|
||||||
const request = nativeStore.get(checkpoint.uploadKey);
|
const request = nativeStore.get(checkpoint.uploadKey);
|
||||||
request.onerror = () => context.nativeFailure(request.error);
|
request.onerror = () => context.fail(mappedFailure(request.error));
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const current = request.result;
|
const current = request.result;
|
||||||
if (
|
if (
|
||||||
@@ -329,16 +363,12 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
current.revision !== expectedRevision))
|
current.revision !== expectedRevision))
|
||||||
) {
|
) {
|
||||||
context.fail(
|
context.fail(
|
||||||
browserDataFailure(
|
checkpointFailure("CONFLICT", { recovery: "RECONCILE" }),
|
||||||
"CONFLICT",
|
|
||||||
"UPLOAD_RECONCILE",
|
|
||||||
{ recovery: "RECONCILE" },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const put = nativeStore.put(checkpoint);
|
const put = nativeStore.put(checkpoint);
|
||||||
put.onerror = () => context.nativeFailure(put.error);
|
put.onerror = () => context.fail(mappedFailure(put.error));
|
||||||
put.onsuccess = () => context.succeed(checkpoint);
|
put.onsuccess = () => context.succeed(checkpoint);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -355,12 +385,9 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
!Number.isSafeInteger(inputValue.expectedRevision) ||
|
!Number.isSafeInteger(inputValue.expectedRevision) ||
|
||||||
inputValue.expectedRevision < 1
|
inputValue.expectedRevision < 1
|
||||||
) {
|
) {
|
||||||
return browserDataFailure(
|
return browserDataFailure("INVALID_INPUT", OPERATION);
|
||||||
"INVALID_INPUT",
|
|
||||||
"UPLOAD_RECONCILE",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const opened = await open(inputValue.signal);
|
const opened = await connection.acquire(inputValue.signal);
|
||||||
if (!opened.ok) return opened;
|
if (!opened.ok) return opened;
|
||||||
return await runCheckpointTransaction<void>(
|
return await runCheckpointTransaction<void>(
|
||||||
opened.value,
|
opened.value,
|
||||||
@@ -368,24 +395,20 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
inputValue.signal,
|
inputValue.signal,
|
||||||
(nativeStore, context) => {
|
(nativeStore, context) => {
|
||||||
const request = nativeStore.get(inputValue.uploadKey);
|
const request = nativeStore.get(inputValue.uploadKey);
|
||||||
request.onerror = () => context.nativeFailure(request.error);
|
request.onerror = () => context.fail(mappedFailure(request.error));
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
if (
|
if (
|
||||||
!isResumableUploadCheckpoint(request.result) ||
|
!isResumableUploadCheckpoint(request.result) ||
|
||||||
request.result.revision !== inputValue.expectedRevision
|
request.result.revision !== inputValue.expectedRevision
|
||||||
) {
|
) {
|
||||||
context.fail(
|
context.fail(
|
||||||
browserDataFailure(
|
checkpointFailure("CONFLICT", { recovery: "RECONCILE" }),
|
||||||
"CONFLICT",
|
|
||||||
"UPLOAD_RECONCILE",
|
|
||||||
{ recovery: "RECONCILE" },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deletion = nativeStore.delete(inputValue.uploadKey);
|
const deletion = nativeStore.delete(inputValue.uploadKey);
|
||||||
deletion.onerror = () =>
|
deletion.onerror = () =>
|
||||||
context.nativeFailure(deletion.error);
|
context.fail(mappedFailure(deletion.error));
|
||||||
deletion.onsuccess = () => context.succeed(undefined);
|
deletion.onsuccess = () => context.succeed(undefined);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -393,9 +416,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
},
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
closed = true;
|
connection.close();
|
||||||
database?.close();
|
|
||||||
database = null;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const store = Object.freeze(storeValue);
|
const store = Object.freeze(storeValue);
|
||||||
@@ -405,82 +426,50 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
): Promise<
|
): Promise<
|
||||||
BrowserDataResult<PartitionDeleteOutcome>
|
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) {
|
if (signal?.aborted) {
|
||||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
return browserDataFailure("ABORTED", OPERATION);
|
||||||
}
|
}
|
||||||
closed = true;
|
connection.close();
|
||||||
database?.close();
|
if (factory === undefined || !canDelete) {
|
||||||
database = null;
|
return browserDataFailure("UNSUPPORTED", OPERATION, {
|
||||||
if (!deleteFactory) {
|
recovery: "READ_ONLY",
|
||||||
return browserDataFailure(
|
});
|
||||||
"UNSUPPORTED",
|
}
|
||||||
"UPLOAD_RECONCILE",
|
// BT-UP-03. Once dispatched the deletion may still commit after this call
|
||||||
{ recovery: "READ_ONLY" },
|
// 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;
|
// Not NOT_APPLIED: the request is still live in the browser.
|
||||||
try {
|
return browserDataSuccess(
|
||||||
request = deleteFactory(databaseName);
|
Object.freeze({
|
||||||
} catch (error) {
|
state: "PENDING" as const,
|
||||||
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
|
effect: "UNKNOWN" as const,
|
||||||
}
|
reason: "BLOCKED_DEADLINE" as const,
|
||||||
// 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,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -488,168 +477,79 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
|
|||||||
return Object.freeze({ store, admin });
|
return Object.freeze({ store, admin });
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionContext<Value> = Readonly<{
|
/**
|
||||||
succeed(value: Value): void;
|
* `durability` is deliberately `undefined` rather than `"default"`: that opens
|
||||||
fail(result: BrowserDataResult<never>): void;
|
* the transaction with no options bag at all, which is what checkpoint writes
|
||||||
nativeFailure(error: unknown): void;
|
* 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>(
|
async function runCheckpointTransaction<Value>(
|
||||||
database: IDBDatabase,
|
database: IDBDatabase,
|
||||||
mode: IDBTransactionMode,
|
mode: "readonly" | "readwrite",
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
execute: (
|
execute: (
|
||||||
store: IDBObjectStore,
|
store: IDBObjectStore,
|
||||||
context: TransactionContext<Value>,
|
context: Readonly<{
|
||||||
|
succeed(value: Value): void;
|
||||||
|
fail(failure: BrowserDataFailure): void;
|
||||||
|
}>,
|
||||||
) => void,
|
) => void,
|
||||||
): Promise<BrowserDataResult<Value>> {
|
): Promise<BrowserDataResult<Value>> {
|
||||||
if (signal?.aborted) {
|
return await runIndexedDbTransaction<Value, BrowserDataFailure>({
|
||||||
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
|
database,
|
||||||
}
|
stores: [CHECKPOINT_STORE],
|
||||||
return await new Promise<BrowserDataResult<Value>>((resolve) => {
|
mode,
|
||||||
let transaction: IDBTransaction;
|
translate,
|
||||||
try {
|
signal,
|
||||||
transaction = database.transaction(CHECKPOINT_STORE, mode);
|
durability: undefined,
|
||||||
} catch (error) {
|
queue: (transaction, context) => {
|
||||||
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 {
|
|
||||||
execute(transaction.objectStore(CHECKPOINT_STORE), 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(
|
async function bindScope(
|
||||||
database: IDBDatabase,
|
database: IDBDatabase,
|
||||||
expected: ScopeBinding,
|
expected: ScopeBinding,
|
||||||
): Promise<BrowserDataResult<void>> {
|
): Promise<BrowserDataResult<void>> {
|
||||||
return await new Promise<BrowserDataResult<void>>((resolve) => {
|
return await runIndexedDbTransaction<void, BrowserDataFailure>({
|
||||||
let transaction: IDBTransaction;
|
database,
|
||||||
try {
|
stores: [GOVERNANCE_STORE],
|
||||||
transaction = database.transaction(GOVERNANCE_STORE, "readwrite");
|
mode: "readwrite",
|
||||||
} catch (error) {
|
translate,
|
||||||
resolve(mapBrowserDataException(error, "UPLOAD_RECONCILE"));
|
durability: undefined,
|
||||||
return;
|
queue: (transaction, context) => {
|
||||||
}
|
const store = transaction.objectStore(GOVERNANCE_STORE);
|
||||||
let failure: BrowserDataResult<never> | null = null;
|
const request = store.get(GOVERNANCE_KEY);
|
||||||
transaction.onerror = () => {
|
request.onerror = () => context.fail(mappedFailure(request.error));
|
||||||
// onabort owns terminal resolution.
|
request.onsuccess = () => {
|
||||||
};
|
if (request.result === undefined) {
|
||||||
transaction.onabort = () =>
|
const add = store.add(expected);
|
||||||
resolve(
|
add.onerror = () => context.fail(mappedFailure(add.error));
|
||||||
failure ??
|
// The binding is the value: without this the committed transaction
|
||||||
mapBrowserDataException(
|
// would report NO_VALUE_PRODUCED, which this store reads as
|
||||||
transaction.error,
|
// CORRUPT_DATA.
|
||||||
"UPLOAD_RECONCILE",
|
add.onsuccess = () => context.succeed(undefined);
|
||||||
),
|
return;
|
||||||
);
|
}
|
||||||
transaction.oncomplete = () => resolve(browserDataSuccess(undefined));
|
if (!sameScopeBinding(request.result, expected)) {
|
||||||
const store = transaction.objectStore(GOVERNANCE_STORE);
|
context.fail(
|
||||||
const request = store.get(GOVERNANCE_KEY);
|
checkpointFailure("POLICY_REJECTED", { recovery: "READ_ONLY" }),
|
||||||
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",
|
|
||||||
);
|
);
|
||||||
transaction.abort();
|
return;
|
||||||
};
|
}
|
||||||
return;
|
context.succeed(undefined);
|
||||||
}
|
};
|
||||||
if (!sameScopeBinding(request.result, expected)) {
|
},
|
||||||
failure = browserDataFailure(
|
|
||||||
"POLICY_REJECTED",
|
|
||||||
"UPLOAD_RECONCILE",
|
|
||||||
{ recovery: "READ_ONLY" },
|
|
||||||
);
|
|
||||||
transaction.abort();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,155 @@ function deletingFactory(
|
|||||||
} as IDBFactory;
|
} 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<string>)];
|
||||||
|
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<void> {
|
||||||
|
for (let tick = 0; tick < 5; tick += 1) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openedCheckpointTransaction(
|
||||||
|
scripted: ReturnType<typeof scriptedFactory>,
|
||||||
|
): Promise<ScriptedTransaction> {
|
||||||
|
await flushTasks();
|
||||||
|
const transaction = scripted.checkpoints[0];
|
||||||
|
if (!transaction) throw new Error("no checkpoint transaction was opened");
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
describe("IndexedDB resumable upload checkpoint", () => {
|
describe("IndexedDB resumable upload checkpoint", () => {
|
||||||
it("length-prefixes scope tuples so delimiter placement cannot collide", () => {
|
it("length-prefixes scope tuples so delimiter placement cannot collide", () => {
|
||||||
const first = uploadCheckpointDatabaseName({
|
const first = uploadCheckpointDatabaseName({
|
||||||
@@ -262,4 +411,112 @@ describe("IndexedDB resumable upload checkpoint", () => {
|
|||||||
error: { code: "ABORTED" },
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user