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>
523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { ResumableUploadCheckpoint } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
|
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
|
import {
|
|
createIndexedDbResumableUploadCheckpointRuntime,
|
|
createIndexedDbResumableUploadCheckpointStore,
|
|
uploadCheckpointDatabaseName,
|
|
} from "../../src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts";
|
|
import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts";
|
|
|
|
const scope = Object.freeze({
|
|
authorityToken: "authority_token_01",
|
|
namespaceToken: "namespace_token_01",
|
|
partitionToken: "partition_token_01",
|
|
});
|
|
|
|
function checkpoint(
|
|
revision: number,
|
|
overrides: Partial<ResumableUploadCheckpoint> = {},
|
|
): ResumableUploadCheckpoint {
|
|
return {
|
|
schemaVersion: 1,
|
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
|
revision,
|
|
state: "ACTIVE",
|
|
uploadKey: "upload_key_01",
|
|
requestBindingSha256: "a".repeat(64),
|
|
fingerprint: {
|
|
algorithm: "SHA-256-PARTS-V1",
|
|
digestHex: "b".repeat(64),
|
|
byteLength: 4,
|
|
partSizeBytes: 4,
|
|
partCount: 1,
|
|
},
|
|
sessionId: "session_01",
|
|
sessionExpiresAtEpochMs: 5_000,
|
|
sessionMaxConcurrency: 1,
|
|
acceptedParts: [],
|
|
updatedAtEpochMs: 1_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function deletingFactory(
|
|
memory: MemoryIndexedDbFactory,
|
|
mode: "SUCCESS" | "BLOCKED",
|
|
): IDBFactory {
|
|
return {
|
|
open: memory.factory.open.bind(memory.factory),
|
|
cmp: memory.factory.cmp.bind(memory.factory),
|
|
deleteDatabase: () => {
|
|
const request = {
|
|
result: undefined,
|
|
error: null,
|
|
transaction: null,
|
|
source: null,
|
|
readyState: "pending",
|
|
onsuccess: null,
|
|
onerror: null,
|
|
onblocked: null,
|
|
onupgradeneeded: null,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
dispatchEvent: () => true,
|
|
} as unknown as IDBOpenDBRequest;
|
|
queueMicrotask(() => {
|
|
if (mode === "SUCCESS") {
|
|
request.onsuccess?.(new Event("success"));
|
|
} else {
|
|
request.onblocked?.({
|
|
oldVersion: 1,
|
|
newVersion: null,
|
|
} as IDBVersionChangeEvent);
|
|
}
|
|
});
|
|
return request;
|
|
},
|
|
databases: async () => [],
|
|
} 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", () => {
|
|
it("length-prefixes scope tuples so delimiter placement cannot collide", () => {
|
|
const first = uploadCheckpointDatabaseName({
|
|
authorityToken: "aaaaaaaa-bbbbbbbb",
|
|
namespaceToken: "cccccccc",
|
|
partitionToken: "dddddddd",
|
|
});
|
|
const second = uploadCheckpointDatabaseName({
|
|
authorityToken: "aaaaaaaa",
|
|
namespaceToken: "bbbbbbbb-cccccccc",
|
|
partitionToken: "dddddddd",
|
|
});
|
|
|
|
expect(first).not.toBe(second);
|
|
expect(first).toContain("17:aaaaaaaa-bbbbbbbb");
|
|
expect(second).toContain("8:aaaaaaaa");
|
|
});
|
|
|
|
it("commits CAS only at transaction completion and rejects stale revisions", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const store = createIndexedDbResumableUploadCheckpointStore({
|
|
scope,
|
|
factory: memory.factory,
|
|
});
|
|
const first = checkpoint(1);
|
|
expect(
|
|
await store.compareAndSwap({
|
|
expectedRevision: null,
|
|
checkpoint: first,
|
|
}),
|
|
).toEqual({ ok: true, value: first });
|
|
expect(await store.read(first.uploadKey)).toEqual({
|
|
ok: true,
|
|
value: first,
|
|
});
|
|
|
|
memory.failNextWriteCommit(
|
|
new DOMException("commit failed", "UnknownError"),
|
|
);
|
|
const failed = await store.compareAndSwap({
|
|
expectedRevision: 1,
|
|
checkpoint: checkpoint(2, { state: "ABORT_PENDING" }),
|
|
});
|
|
expect(failed).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE" },
|
|
});
|
|
expect(await store.read(first.uploadKey)).toEqual({
|
|
ok: true,
|
|
value: first,
|
|
});
|
|
|
|
expect(
|
|
await store.compareAndSwap({
|
|
expectedRevision: 2,
|
|
checkpoint: checkpoint(3),
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONFLICT", recovery: "RECONCILE" },
|
|
});
|
|
});
|
|
|
|
it("rejects unknown persisted fields so URLs and credentials cannot enter a checkpoint", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const store = createIndexedDbResumableUploadCheckpointStore({
|
|
scope,
|
|
factory: memory.factory,
|
|
});
|
|
const smuggled = {
|
|
...checkpoint(1),
|
|
signedUrl: "https://object.invalid/secret?signature=value",
|
|
} as unknown as ResumableUploadCheckpoint;
|
|
expect(
|
|
await store.compareAndSwap({
|
|
expectedRevision: null,
|
|
checkpoint: smuggled,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
expect(await store.read("upload_key_01")).toEqual({
|
|
ok: true,
|
|
value: null,
|
|
});
|
|
});
|
|
|
|
it("closes the bound partition before successful lifecycle deletion", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
|
scope,
|
|
factory: deletingFactory(memory, "SUCCESS"),
|
|
blockedTimeoutMs: 10,
|
|
});
|
|
expect(
|
|
await runtime.store.compareAndSwap({
|
|
expectedRevision: null,
|
|
checkpoint: checkpoint(1),
|
|
}),
|
|
).toMatchObject({ ok: true });
|
|
expect(await runtime.admin.deletePartition()).toEqual({
|
|
ok: true,
|
|
value: { state: "DELETED", effect: "APPLIED" },
|
|
});
|
|
expect(await runtime.store.read("upload_key_01")).toMatchObject({
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", recovery: "RESUME" },
|
|
});
|
|
});
|
|
|
|
it("returns PENDING UNKNOWN when deleteDatabase is still blocked", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const factory = deletingFactory(memory, "BLOCKED");
|
|
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
|
scope,
|
|
factory,
|
|
blockedTimeoutMs: 1,
|
|
});
|
|
// BT-UP-03. The native request is still live, so the deadline is not
|
|
// evidence that nothing happened.
|
|
expect(await runtime.admin.deletePartition()).toEqual({
|
|
ok: true,
|
|
value: {
|
|
state: "PENDING",
|
|
effect: "UNKNOWN",
|
|
reason: "BLOCKED_DEADLINE",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps the checkpoint store closed until a pending delete is resolved externally", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const factory = deletingFactory(memory, "BLOCKED");
|
|
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
|
scope,
|
|
factory,
|
|
blockedTimeoutMs: 1,
|
|
});
|
|
expect(await runtime.admin.deletePartition()).toMatchObject({
|
|
ok: true,
|
|
value: { state: "PENDING" },
|
|
});
|
|
|
|
// A second runtime over the same realm and database would race an unknown
|
|
// native effect.
|
|
expect(() =>
|
|
createIndexedDbResumableUploadCheckpointRuntime({
|
|
scope,
|
|
factory,
|
|
blockedTimeoutMs: 1,
|
|
}),
|
|
).toThrow(TypeError);
|
|
});
|
|
|
|
it("never reports a false abort after irreversible deleteDatabase dispatch", async () => {
|
|
const memory = new MemoryIndexedDbFactory();
|
|
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
|
scope,
|
|
factory: deletingFactory(memory, "SUCCESS"),
|
|
});
|
|
const controller = new AbortController();
|
|
const deletion = runtime.admin.deletePartition(controller.signal);
|
|
controller.abort();
|
|
expect(await deletion).toEqual({
|
|
ok: true,
|
|
value: { state: "DELETED", effect: "APPLIED" },
|
|
});
|
|
|
|
const preAborted = new AbortController();
|
|
preAborted.abort();
|
|
const second = createIndexedDbResumableUploadCheckpointRuntime({
|
|
scope: { ...scope, partitionToken: "partition_token_02" },
|
|
factory: deletingFactory(new MemoryIndexedDbFactory(), "SUCCESS"),
|
|
});
|
|
expect(
|
|
await second.admin.deletePartition(preAborted.signal),
|
|
).toMatchObject({
|
|
ok: false,
|
|
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" },
|
|
});
|
|
});
|
|
});
|