refactor: move the OPFS journal onto the IndexedDB kernel, and make the fake
IndexedDB enforce unique indexes 저널은 이제 트랜잭션 안 요청 28곳이 각자 오류를 보고한다. 코드 줄은 1744 -> 1724로 줄었고 전체 줄이 1817 -> 1881로 는 것은 주석이다. 이행 전 이 파일의 주석은 6줄이었다. 사양의 OP-1 전제는 틀렸다. "중복 put의 답이 달라진다"고 봤으나 이행 전후 모두 CONFLICT / retryable:false / recovery:NONE이다. 요청 오류를 아무도 처리하지 않으면 스토어가 바로 그 에러로 abort해서 transaction.error가 request.error와 같기 때문이다. 바뀐 것은 답이 아니라 출처다. 그 과정에서 가짜 IndexedDB가 unique 인덱스를 전혀 강제하지 않는 것을 찾았다. 보강 전에는 중복 begin이 ok:true로 성공했다 — 브라우저가 거부할 상태를 테스트가 조용히 허용하고 있었다. put/add에 검사를 넣었다. unique:true 인덱스는 레포 전체에서 이 저널의 2개뿐이라 반경이 좁고, 전체 test:unit으로 파급이 없음을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3366a81f0f
commit
217c1dd52a
@@ -146,6 +146,48 @@ function compareKeys(first: IDBValidKey, second: IDBValidKey): number {
|
||||
return String(left) < String(right) ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unique index rejects a second row whose index key already belongs to
|
||||
* another primary key, which is how a browser reports it: a request-level
|
||||
* `ConstraintError` that then aborts the transaction.
|
||||
*
|
||||
* The fake enforced primary keys only, so the one `unique: true` index in the
|
||||
* repository — `indexeddb-opfs-journal.ts`'s logical-key index, whose store is
|
||||
* keyed by `transactionId` instead — could never fail here the way it fails in
|
||||
* a browser. Every other index in `src/adapters` is non-unique, so this check
|
||||
* is a no-op for them.
|
||||
*/
|
||||
function assertUniqueIndexes(
|
||||
state: StoreState,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
for (const [name, index] of state.indexes) {
|
||||
if (!index.unique || typeof index.keyPath !== "string") continue;
|
||||
const indexKey = readPath(value, index.keyPath);
|
||||
// An absent index key keeps the row out of the index entirely, so it
|
||||
// cannot collide with anything.
|
||||
if (indexKey === undefined) continue;
|
||||
for (const [otherKey, otherValue] of state.data) {
|
||||
// Replacing a row never collides with the row it replaces.
|
||||
if (otherKey === key) continue;
|
||||
const otherIndexKey = readPath(otherValue, index.keyPath);
|
||||
if (otherIndexKey === undefined) continue;
|
||||
if (
|
||||
compareKeys(
|
||||
indexKey as IDBValidKey,
|
||||
otherIndexKey as IDBValidKey,
|
||||
) === 0
|
||||
) {
|
||||
throw new DOMException(
|
||||
`Unique index ${name} already holds this key.`,
|
||||
"ConstraintError",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeUpgradeTransaction {
|
||||
aborted = false;
|
||||
|
||||
@@ -483,6 +525,7 @@ class FakeObjectStore {
|
||||
this.assertWritable();
|
||||
return this.request(() => {
|
||||
const key = primaryKey(this.state, value);
|
||||
assertUniqueIndexes(this.state, key, value);
|
||||
this.state.data.set(key, structuredClone(value));
|
||||
return key as IDBValidKey;
|
||||
});
|
||||
@@ -495,6 +538,7 @@ class FakeObjectStore {
|
||||
if (this.state.data.has(key)) {
|
||||
throw new DOMException("Key already exists.", "ConstraintError");
|
||||
}
|
||||
assertUniqueIndexes(this.state, key, value);
|
||||
this.state.data.set(key, structuredClone(value));
|
||||
return key as IDBValidKey;
|
||||
});
|
||||
|
||||
@@ -95,17 +95,98 @@ function prepared(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records every `transaction.abort()` the journal itself calls, with the mode
|
||||
* of the transaction it aborted.
|
||||
*
|
||||
* A failure code alone cannot tell `fail` (record and abort) from
|
||||
* `requestFailed` (record only): the fake aborts a transaction on a request
|
||||
* error by itself, so both land on the same code while only one of them stops
|
||||
* the writes queued behind it.
|
||||
*/
|
||||
function trackAborts(memory: MemoryIndexedDbFactory): Readonly<{
|
||||
factory: IDBFactory;
|
||||
aborts: readonly string[];
|
||||
}> {
|
||||
const aborts: string[] = [];
|
||||
const wrapped = new WeakSet<object>();
|
||||
const wrapDatabase = (database: IDBDatabase): IDBDatabase => {
|
||||
if (wrapped.has(database)) return database;
|
||||
wrapped.add(database);
|
||||
const openTransaction = database.transaction.bind(database);
|
||||
Object.defineProperty(database, "transaction", {
|
||||
configurable: true,
|
||||
value: (...args: Parameters<IDBDatabase["transaction"]>) => {
|
||||
const transaction = openTransaction(...args);
|
||||
const abort = transaction.abort.bind(transaction);
|
||||
Object.defineProperty(transaction, "abort", {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
aborts.push(transaction.mode);
|
||||
abort();
|
||||
},
|
||||
});
|
||||
return transaction;
|
||||
},
|
||||
});
|
||||
return database;
|
||||
};
|
||||
const factory = {
|
||||
cmp: (first: IDBValidKey, second: IDBValidKey) =>
|
||||
memory.factory.cmp(first, second),
|
||||
open: (name: string, version?: number) => {
|
||||
const request = memory.factory.open(name, version);
|
||||
let stored: IDBDatabase | undefined = request.result;
|
||||
if (stored) wrapDatabase(stored);
|
||||
Object.defineProperty(request, "result", {
|
||||
configurable: true,
|
||||
get: () => stored,
|
||||
set: (value: IDBDatabase | undefined) => {
|
||||
stored = value ? wrapDatabase(value) : value;
|
||||
},
|
||||
});
|
||||
return request;
|
||||
},
|
||||
} as unknown as IDBFactory;
|
||||
return Object.freeze({ factory, aborts });
|
||||
}
|
||||
|
||||
function createHarness(factory = new MemoryIndexedDbFactory()) {
|
||||
let tokenSequence = 0;
|
||||
const journal = createIndexedDbOpfsJournal({
|
||||
authorityToken,
|
||||
factory: factory.factory,
|
||||
// `listCommittedObjects` is the only caller that needs a key range, and
|
||||
// this environment has no global one.
|
||||
keyRange: factory.keyRange,
|
||||
createFencingToken: () =>
|
||||
`fencing_${String(++tokenSequence).padStart(8, "0")}`,
|
||||
});
|
||||
return { factory, journal };
|
||||
}
|
||||
|
||||
async function commitObject(
|
||||
journal: ReturnType<typeof createHarness>["journal"],
|
||||
transactionId: string,
|
||||
objectId: string,
|
||||
byteLength: number,
|
||||
): Promise<void> {
|
||||
const input = beginInput(transactionId, objectId, byteLength);
|
||||
const begun = await journal.begin(input);
|
||||
if (!begun.ok) throw new Error(`begin failed for ${objectId}`);
|
||||
const token = begun.value.fencingToken;
|
||||
const ready = await journal.markFilesReady(
|
||||
transactionId,
|
||||
token,
|
||||
prepared(input),
|
||||
);
|
||||
if (!ready.ok) throw new Error(`markFilesReady failed for ${objectId}`);
|
||||
const committed = await journal.commitPut(transactionId, token);
|
||||
if (!committed.ok) throw new Error(`commitPut failed for ${objectId}`);
|
||||
const completed = await journal.complete(transactionId, token);
|
||||
if (!completed.ok) throw new Error(`complete failed for ${objectId}`);
|
||||
}
|
||||
|
||||
describe("IndexedDB OPFS journal", () => {
|
||||
it("fences stale writers and atomically publishes object, budget and chunk refs", async () => {
|
||||
const { factory, journal } = createHarness();
|
||||
@@ -428,4 +509,234 @@ describe("IndexedDB OPFS journal", () => {
|
||||
error: { code: "CORRUPT_DATA" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a unique logical-key violation as the request's own ConstraintError", async () => {
|
||||
// OP-1. Two open journal rows for one object collide on the `by-logical-key`
|
||||
// index, which is the only `unique: true` index in `src/adapters`. The
|
||||
// violation belongs to the `add` request, not to the transaction: the
|
||||
// journal never asks for the row's uniqueness, it asks the index to enforce
|
||||
// it, so the answer has to be the request's `ConstraintError` rather than
|
||||
// whatever the transaction happened to abort with.
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const tracker = trackAborts(memory);
|
||||
let tokenSequence = 0;
|
||||
const journal = createIndexedDbOpfsJournal({
|
||||
authorityToken,
|
||||
factory: tracker.factory,
|
||||
createFencingToken: () =>
|
||||
`fencing_${String(++tokenSequence).padStart(8, "0")}`,
|
||||
});
|
||||
|
||||
const first = await journal.begin(
|
||||
beginInput("transaction_dupe0001", "object_dupe_1234", 1),
|
||||
);
|
||||
expect(first).toMatchObject({ ok: true });
|
||||
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput("transaction_dupe0002", "object_dupe_1234", 1),
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "CONFLICT",
|
||||
operation: "INDEXEDDB_WRITE",
|
||||
retryable: false,
|
||||
recovery: "NONE",
|
||||
},
|
||||
});
|
||||
// `mapIndexedDbException`'s ConstraintError arm carries no recovery, while
|
||||
// the journal's own `conflict()` helper carries REOPEN. The NONE above is
|
||||
// what proves the answer came from the native error rather than from a
|
||||
// predicate the journal evaluated itself.
|
||||
expect(tracker.aborts).toEqual([]);
|
||||
// The losing transaction rolled back whole: no budget reservation from the
|
||||
// second `begin` survives, so the partition still admits its full budget.
|
||||
expect(memory.readRaw("opfs-budgets", `${scope.authorityToken}|${scope.namespaceToken}|${scope.partitionToken}`)).toMatchObject({
|
||||
reservedBytes: 1,
|
||||
});
|
||||
expect(
|
||||
(await journal.listIncomplete(10)) as unknown,
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { transactions: [{ transactionId: "transaction_dupe0001" }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the first explicit failure win over a later success in the same transaction", async () => {
|
||||
// OP-2. `begin` runs two independent request chains: the scope-binding
|
||||
// chain can fail while the object/budget chain is still queued to succeed.
|
||||
// The binding chain's `get` is issued first, so its verdict always lands
|
||||
// first and the later `succeed` never reaches the caller. The abort count
|
||||
// is what separates "the journal rejected this" from "the store did".
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const tracker = trackAborts(memory);
|
||||
let tokenSequence = 0;
|
||||
const journal = createIndexedDbOpfsJournal({
|
||||
authorityToken,
|
||||
factory: tracker.factory,
|
||||
createFencingToken: () =>
|
||||
`fencing_${String(++tokenSequence).padStart(8, "0")}`,
|
||||
});
|
||||
|
||||
const bound = await journal.begin(
|
||||
beginInput("transaction_order001", "object_order_1234", 1),
|
||||
);
|
||||
if (!bound.ok) throw new Error("binding begin failed");
|
||||
await journal.rollback(
|
||||
"transaction_order001",
|
||||
bound.value.fencingToken,
|
||||
);
|
||||
const abortsBefore = tracker.aborts.length;
|
||||
|
||||
expect(
|
||||
await journal.begin({
|
||||
...beginInput("transaction_order002", "object_order_5678", 1),
|
||||
targetStoragePolicy: { ...policy, owner: "rebound-owner" },
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "POLICY_REJECTED",
|
||||
operation: "INDEXEDDB_WRITE",
|
||||
retryable: false,
|
||||
recovery: "READ_ONLY",
|
||||
},
|
||||
});
|
||||
// Exactly one abort, from the binding chain's `fail`. A second would mean
|
||||
// the budget chain also reached a verdict.
|
||||
expect(tracker.aborts.length - abortsBefore).toBe(1);
|
||||
expect(memory.readRaw("opfs-journal", "transaction_order002")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a commit failure attributable to the transaction rather than a request", async () => {
|
||||
// The commit itself fails, so no request ever errors. The journal must not
|
||||
// claim an abort it did not perform, and the quota code has to come from
|
||||
// the transaction's own error.
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const tracker = trackAborts(memory);
|
||||
let tokenSequence = 0;
|
||||
const journal = createIndexedDbOpfsJournal({
|
||||
authorityToken,
|
||||
factory: tracker.factory,
|
||||
createFencingToken: () =>
|
||||
`fencing_${String(++tokenSequence).padStart(8, "0")}`,
|
||||
});
|
||||
|
||||
memory.failNextWriteCommit(
|
||||
new DOMException("quota", "QuotaExceededError"),
|
||||
);
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput("transaction_quota001", "object_quota_1234", 1),
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "QUOTA_EXCEEDED",
|
||||
operation: "INDEXEDDB_WRITE",
|
||||
retryable: false,
|
||||
recovery: "READ_ONLY",
|
||||
},
|
||||
});
|
||||
expect(tracker.aborts).toEqual([]);
|
||||
});
|
||||
|
||||
it("pages incomplete transactions and validates a row before the page limit", async () => {
|
||||
const { factory, journal } = createHarness();
|
||||
for (const index of [1, 2, 3]) {
|
||||
const id = `transaction_page000${index}`;
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput(id, `object_page_000${index}`, 1),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
}
|
||||
|
||||
expect(await journal.listIncomplete(2)).toMatchObject({
|
||||
ok: true,
|
||||
value: { moreAvailable: true },
|
||||
});
|
||||
const page = await journal.listIncomplete(2);
|
||||
if (!page.ok) throw new Error("listIncomplete failed");
|
||||
expect(page.value.transactions).toHaveLength(2);
|
||||
|
||||
const whole = await journal.listIncomplete(10);
|
||||
if (!whole.ok) throw new Error("listIncomplete failed");
|
||||
expect(whole.value.transactions).toHaveLength(3);
|
||||
expect(whole.value.moreAvailable).toBe(false);
|
||||
|
||||
// A corrupt row one past the requested page still fails the read. The
|
||||
// journal validates a row before it checks the limit, so a page that is
|
||||
// already full does not hide the next row's damage behind `moreAvailable`.
|
||||
factory.seed("opfs-journal", {
|
||||
transactionId: "transaction_page9999",
|
||||
startedAtEpochMs: 9_000,
|
||||
corrupt: true,
|
||||
});
|
||||
expect(await journal.listIncomplete(3)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CORRUPT_DATA", recovery: "READ_ONLY" },
|
||||
});
|
||||
});
|
||||
|
||||
it("pages committed objects and reports where the next page resumes", async () => {
|
||||
const { journal } = createHarness();
|
||||
await commitObject(journal, "transaction_obj00001", "object_aaaa0001", 1);
|
||||
await commitObject(journal, "transaction_obj00002", "object_bbbb0002", 1);
|
||||
await commitObject(journal, "transaction_obj00003", "object_cccc0003", 1);
|
||||
|
||||
const first = await journal.listCommittedObjects({ scope, limit: 2 });
|
||||
if (!first.ok) throw new Error("listCommittedObjects failed");
|
||||
expect(first.value.objects.map((o) => o.descriptor.objectId)).toEqual([
|
||||
"object_aaaa0001",
|
||||
"object_bbbb0002",
|
||||
]);
|
||||
// A page cut short by the limit names the cursor the caller resumes from.
|
||||
expect(first.value.moreAvailable).toBe(true);
|
||||
expect(first.value.nextObjectId).toBe("object_bbbb0002");
|
||||
|
||||
const second = await journal.listCommittedObjects({
|
||||
scope,
|
||||
limit: 2,
|
||||
afterObjectId: first.value.nextObjectId ?? undefined,
|
||||
});
|
||||
if (!second.ok) throw new Error("listCommittedObjects failed");
|
||||
expect(second.value.objects.map((o) => o.descriptor.objectId)).toEqual([
|
||||
"object_cccc0003",
|
||||
]);
|
||||
// An exhausted scan has nothing to resume from, so it reports neither.
|
||||
expect(second.value.moreAvailable).toBe(false);
|
||||
expect(second.value.nextObjectId).toBeNull();
|
||||
});
|
||||
|
||||
it("answers UNSUPPORTED once the journal is closed", async () => {
|
||||
const { factory, journal } = createHarness();
|
||||
expect(
|
||||
await journal.getCommittedObject(scope, "object_12345678"),
|
||||
).toEqual({ ok: true, value: null });
|
||||
|
||||
journal.close();
|
||||
expect(factory.isConnectionClosed()).toBe(true);
|
||||
// A closed journal is not a retryable outage and not an abort: the store
|
||||
// is gone for this realm, so the caller is told to work online. The
|
||||
// failure names INDEXEDDB_OPEN rather than the operation that asked,
|
||||
// because it belongs to the open that never happened.
|
||||
expect(
|
||||
await journal.getCommittedObject(scope, "object_12345678"),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNSUPPORTED",
|
||||
operation: "INDEXEDDB_OPEN",
|
||||
retryable: false,
|
||||
recovery: "ONLINE_ONLY",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput("transaction_closed001", "object_closed_001", 1),
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { code: "UNSUPPORTED" } });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user