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>
1882 lines
63 KiB
TypeScript
1882 lines
63 KiB
TypeScript
import type {
|
|
BeginOpfsJournalTransaction,
|
|
OpfsCommittedObjectPage,
|
|
OpfsJournalPage,
|
|
OpfsJournalPort,
|
|
OpfsJournalTransaction,
|
|
OpfsPreparedObject,
|
|
OpfsStorageScope,
|
|
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
|
|
import {
|
|
assertValidStoragePolicy,
|
|
type BrowserDataFailure,
|
|
type BrowserDataOperation,
|
|
type BrowserDataResult,
|
|
type BrowserStoragePolicy,
|
|
} from "../../../application/ports/browser-file-storage/shared.ts";
|
|
import {
|
|
browserDataFailure,
|
|
browserDataSuccess,
|
|
} from "../../browser-file-storage/result.ts";
|
|
import { snapshotAbortTimers } from "../../platform/abortable-operation.ts";
|
|
import {
|
|
createIndexedDbConnection,
|
|
openIndexedDbDatabase,
|
|
type IndexedDbTranslate,
|
|
} from "../../platform/indexeddb-connection.ts";
|
|
import {
|
|
onIndexedDbRequest,
|
|
runIndexedDbTransaction,
|
|
walkIndexedDbCursor,
|
|
type IndexedDbRequestSink,
|
|
type IndexedDbTransactionContext,
|
|
} from "../../platform/indexeddb-transaction.ts";
|
|
import { mapIndexedDbException } from "../indexeddb/indexeddb-failure.ts";
|
|
import { isValidOpfsStorageScope } from "./opfs-policy.ts";
|
|
|
|
export type IndexedDbOpfsJournalDependencies = Readonly<{
|
|
authorityToken: string;
|
|
databaseName?: string;
|
|
factory?: IDBFactory;
|
|
keyRange?: Pick<typeof IDBKeyRange, "bound">;
|
|
createFencingToken?: () => string;
|
|
blockedTimeoutMs?: number;
|
|
scheduler?: Readonly<{
|
|
setTimeout(callback: () => void, milliseconds: number): unknown;
|
|
clearTimeout(handle: unknown): void;
|
|
}>;
|
|
observe?: (
|
|
event: Readonly<{
|
|
operation: BrowserDataOperation;
|
|
outcome: "SUCCEEDED" | "FAILED";
|
|
failureCode?: BrowserDataFailure["code"];
|
|
}>,
|
|
) => void;
|
|
}>;
|
|
|
|
export interface IndexedDbOpfsJournal extends OpfsJournalPort {
|
|
close(): void;
|
|
}
|
|
|
|
type TransactionContext<Value> = IndexedDbTransactionContext<
|
|
Value,
|
|
BrowserDataFailure
|
|
>;
|
|
|
|
type StoredJournalRow = OpfsJournalTransaction &
|
|
Readonly<{ logicalKey: string }>;
|
|
|
|
type StoredObjectRow = Readonly<{
|
|
logicalKey: string;
|
|
scopeObjectKey: string;
|
|
scopeKey: string;
|
|
objectId: string;
|
|
preparedObject: OpfsPreparedObject;
|
|
}>;
|
|
|
|
type StoredBudgetRow = Readonly<{
|
|
budgetKey: string;
|
|
namespace: string;
|
|
authorityToken: string;
|
|
namespaceToken: string;
|
|
partitionToken: string;
|
|
hardBudgetBytes: number;
|
|
committedBytes: number;
|
|
reservedBytes: number;
|
|
}>;
|
|
|
|
type StoredScopeBinding = Readonly<{
|
|
scopeKey: string;
|
|
namespace: string;
|
|
authorityToken: string;
|
|
namespaceToken: string;
|
|
partitionToken: string;
|
|
policyFingerprint: string;
|
|
}>;
|
|
|
|
type StoredLogicalScopeBinding = Readonly<{
|
|
logicalScopeKey: string;
|
|
physicalScopeKey: string;
|
|
authorityToken: string;
|
|
namespace: string;
|
|
namespaceToken: string;
|
|
partitionToken: string;
|
|
}>;
|
|
|
|
type StoredChunkReference = Readonly<{
|
|
referenceKey: string;
|
|
scopeKey: string;
|
|
digestHex: string;
|
|
referenceCount: number;
|
|
}>;
|
|
|
|
const DATABASE_VERSION = 1;
|
|
const JOURNAL_STORE = "opfs-journal";
|
|
const OBJECT_STORE = "opfs-objects";
|
|
const BUDGET_STORE = "opfs-budgets";
|
|
const SCOPE_BINDING_STORE = "opfs-scope-bindings";
|
|
const LOGICAL_SCOPE_BINDING_STORE = "opfs-logical-scope-bindings";
|
|
const CHUNK_REFERENCE_STORE = "opfs-chunk-references";
|
|
const LOGICAL_KEY_INDEX = "by-logical-key";
|
|
const STARTED_AT_INDEX = "by-started-at";
|
|
const SCOPE_OBJECT_INDEX = "by-scope-object";
|
|
const SAFE_DATABASE_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
|
|
const SAFE_BOUNDARY_ID = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
const SHA256_HEX = /^[a-f0-9]{64}$/u;
|
|
|
|
export function opfsJournalDatabaseName(
|
|
authorityToken: string,
|
|
): string {
|
|
if (!SAFE_BOUNDARY_ID.test(authorityToken)) {
|
|
throw new TypeError("OPFS authority token is invalid.");
|
|
}
|
|
return `ca-frontend-opfs-metadata-v1:${authorityToken}`;
|
|
}
|
|
|
|
export function createIndexedDbOpfsJournal(
|
|
dependencies: IndexedDbOpfsJournalDependencies,
|
|
): IndexedDbOpfsJournal {
|
|
const expectedDatabaseName = opfsJournalDatabaseName(
|
|
dependencies.authorityToken,
|
|
);
|
|
const databaseName = dependencies.databaseName ?? expectedDatabaseName;
|
|
const factory =
|
|
dependencies.factory ??
|
|
(typeof globalThis.indexedDB === "undefined"
|
|
? undefined
|
|
: globalThis.indexedDB);
|
|
const keyRange =
|
|
dependencies.keyRange ??
|
|
(typeof globalThis.IDBKeyRange === "undefined"
|
|
? undefined
|
|
: globalThis.IDBKeyRange);
|
|
const createFencingToken =
|
|
dependencies.createFencingToken ??
|
|
(() => globalThis.crypto.randomUUID());
|
|
const blockedTimeoutMs = dependencies.blockedTimeoutMs ?? 10_000;
|
|
if (
|
|
!SAFE_BOUNDARY_ID.test(dependencies.authorityToken) ||
|
|
!SAFE_DATABASE_NAME.test(databaseName) ||
|
|
databaseName !== expectedDatabaseName ||
|
|
!Number.isSafeInteger(blockedTimeoutMs) ||
|
|
blockedTimeoutMs < 0
|
|
) {
|
|
throw new TypeError("IndexedDB OPFS journal configuration is invalid.");
|
|
}
|
|
|
|
// X-AUDIT-02. The timer callables are captured once, bound to their
|
|
// receiver, so replacing a global after composition cannot change how an
|
|
// open already in flight is bounded.
|
|
const timers = snapshotAbortTimers(
|
|
dependencies.scheduler ?? {
|
|
setTimeout: (callback: () => void, milliseconds: number): unknown =>
|
|
globalThis.setTimeout(callback, milliseconds),
|
|
clearTimeout: (handle: unknown): void => {
|
|
globalThis.clearTimeout(
|
|
handle as ReturnType<typeof globalThis.setTimeout>,
|
|
);
|
|
},
|
|
},
|
|
);
|
|
|
|
/**
|
|
* 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 dropping the cached
|
|
* connection so the next call reopens — which the kernel already does — was
|
|
* this journal's entire listener body.
|
|
*/
|
|
const connection = createIndexedDbConnection<BrowserDataFailure>({
|
|
translate: translateFor("INDEXEDDB_OPEN"),
|
|
open: (signal) =>
|
|
factory === undefined
|
|
? Promise.resolve(
|
|
browserDataFailure("UNSUPPORTED", "INDEXEDDB_OPEN", {
|
|
recovery: "ONLINE_ONLY",
|
|
}),
|
|
)
|
|
: openIndexedDbDatabase<BrowserDataFailure>({
|
|
factory,
|
|
databaseName,
|
|
version: DATABASE_VERSION,
|
|
translate: translateFor("INDEXEDDB_OPEN"),
|
|
signal,
|
|
blockedTimeoutMs,
|
|
timers,
|
|
upgrade: ({ database, oldVersion }) => {
|
|
// Version 1 is the only schema this journal has ever had, so any
|
|
// other starting point belongs to a database it does not own.
|
|
// Rejecting aborts the versionchange transaction, which is what
|
|
// the hand-written `onupgradeneeded` did.
|
|
if (oldVersion !== 0) return { kind: "REJECTED" };
|
|
const journalStore = database.createObjectStore(JOURNAL_STORE, {
|
|
keyPath: "transactionId",
|
|
});
|
|
journalStore.createIndex(
|
|
LOGICAL_KEY_INDEX,
|
|
"logicalKey",
|
|
{ unique: true },
|
|
);
|
|
journalStore.createIndex(
|
|
STARTED_AT_INDEX,
|
|
"startedAtEpochMs",
|
|
{ unique: false },
|
|
);
|
|
const objectStore = database.createObjectStore(OBJECT_STORE, {
|
|
keyPath: "logicalKey",
|
|
});
|
|
objectStore.createIndex(
|
|
SCOPE_OBJECT_INDEX,
|
|
"scopeObjectKey",
|
|
{ unique: true },
|
|
);
|
|
database.createObjectStore(BUDGET_STORE, {
|
|
keyPath: "budgetKey",
|
|
});
|
|
database.createObjectStore(SCOPE_BINDING_STORE, {
|
|
keyPath: "scopeKey",
|
|
});
|
|
database.createObjectStore(LOGICAL_SCOPE_BINDING_STORE, {
|
|
keyPath: "logicalScopeKey",
|
|
});
|
|
database.createObjectStore(CHUNK_REFERENCE_STORE, {
|
|
keyPath: "referenceKey",
|
|
});
|
|
return { kind: "APPLIED" };
|
|
},
|
|
}),
|
|
});
|
|
|
|
const journal: IndexedDbOpfsJournal = {
|
|
async getCommittedObject(scope, objectId) {
|
|
if (
|
|
!isBoundScope(scope) ||
|
|
!validScopedObject(scope, objectId)
|
|
) {
|
|
return browserDataFailure("INVALID_INPUT", "INDEXEDDB_READ");
|
|
}
|
|
return await withDatabase("INDEXEDDB_READ", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[OBJECT_STORE],
|
|
"readonly",
|
|
"INDEXEDDB_READ",
|
|
(transaction, context) => {
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
transaction
|
|
.objectStore(OBJECT_STORE)
|
|
.get(logicalObjectKey(scope, objectId)),
|
|
context,
|
|
(stored) => {
|
|
if (stored === undefined) {
|
|
context.succeed(null);
|
|
return;
|
|
}
|
|
if (
|
|
!isStoredObjectRow(stored) ||
|
|
!sameScope(stored.preparedObject.descriptor.scope, scope)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_READ"));
|
|
return;
|
|
}
|
|
context.succeed(stored.preparedObject);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
async begin(input) {
|
|
if (
|
|
!isBoundScope(input.scope) ||
|
|
!isBeginTransaction(input)
|
|
) {
|
|
return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE");
|
|
}
|
|
const fencingToken = createFencingToken();
|
|
if (!SAFE_BOUNDARY_ID.test(fencingToken)) {
|
|
return browserDataFailure("UNAVAILABLE", "INDEXEDDB_WRITE", {
|
|
recovery: "REOPEN",
|
|
});
|
|
}
|
|
return await withDatabase("INDEXEDDB_WRITE", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[
|
|
JOURNAL_STORE,
|
|
OBJECT_STORE,
|
|
BUDGET_STORE,
|
|
SCOPE_BINDING_STORE,
|
|
LOGICAL_SCOPE_BINDING_STORE,
|
|
],
|
|
"readwrite",
|
|
"INDEXEDDB_WRITE",
|
|
(nativeTransaction, context) => {
|
|
const bindingStore =
|
|
nativeTransaction.objectStore(SCOPE_BINDING_STORE);
|
|
const scopeKey = storageScopeKey(input.scope);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
bindingStore.get(scopeKey),
|
|
context,
|
|
(storedBinding) => {
|
|
if (
|
|
storedBinding !== undefined &&
|
|
!isScopeBinding(storedBinding)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const expectedBinding = scopeBinding(
|
|
input.scope,
|
|
input.targetStoragePolicy,
|
|
);
|
|
const logicalBindingStore = nativeTransaction.objectStore(
|
|
LOGICAL_SCOPE_BINDING_STORE,
|
|
);
|
|
const expectedLogicalBinding = logicalScopeBinding(
|
|
input.scope,
|
|
);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
logicalBindingStore.get(
|
|
expectedLogicalBinding.logicalScopeKey,
|
|
),
|
|
context,
|
|
(storedLogicalBinding) => {
|
|
if (
|
|
storedLogicalBinding !== undefined &&
|
|
!isLogicalScopeBinding(storedLogicalBinding)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (
|
|
(storedBinding &&
|
|
stableJson(storedBinding) !==
|
|
stableJson(expectedBinding)) ||
|
|
(storedLogicalBinding &&
|
|
stableJson(storedLogicalBinding) !==
|
|
stableJson(expectedLogicalBinding))
|
|
) {
|
|
context.fail(policyRejected("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (
|
|
(storedBinding === undefined) !==
|
|
(storedLogicalBinding === undefined)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (!storedBinding) {
|
|
watchWrite(bindingStore.add(expectedBinding), context);
|
|
watchWrite(
|
|
logicalBindingStore.add(expectedLogicalBinding),
|
|
context,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
const logicalKey = logicalObjectKey(
|
|
input.scope,
|
|
input.objectId,
|
|
);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
nativeTransaction.objectStore(OBJECT_STORE).get(logicalKey),
|
|
context,
|
|
(storedCurrent) => {
|
|
if (
|
|
storedCurrent !== undefined &&
|
|
!isStoredObjectRow(storedCurrent)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const current: OpfsPreparedObject | undefined =
|
|
storedCurrent?.preparedObject;
|
|
if (
|
|
!generationMatches(
|
|
current,
|
|
input.expectedGeneration,
|
|
) ||
|
|
input.targetGeneration !==
|
|
(current?.descriptor.generation ?? 0) + 1
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const currentBytes =
|
|
current?.descriptor.byteLength ?? 0;
|
|
const reservedBytes =
|
|
input.mutation === "PUT"
|
|
? Math.max(0, input.targetByteLength - currentBytes)
|
|
: 0;
|
|
const budgetKey = storageBudgetKey(input.scope);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
nativeTransaction.objectStore(BUDGET_STORE).get(budgetKey),
|
|
context,
|
|
(budgetResult) => {
|
|
if (
|
|
budgetResult !== undefined &&
|
|
!isBudgetRow(budgetResult)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (
|
|
budgetResult &&
|
|
(budgetResult.hardBudgetBytes !==
|
|
input.targetStoragePolicy.hardBudgetBytes ||
|
|
budgetResult.namespace !== input.scope.namespace ||
|
|
budgetResult.authorityToken !==
|
|
input.scope.authorityToken ||
|
|
budgetResult.namespaceToken !==
|
|
input.scope.namespaceToken ||
|
|
budgetResult.partitionToken !==
|
|
input.scope.partitionToken)
|
|
) {
|
|
context.fail(policyRejected("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (!budgetResult && current) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const budget: StoredBudgetRow =
|
|
budgetResult ?? {
|
|
budgetKey,
|
|
namespace: input.scope.namespace,
|
|
authorityToken: input.scope.authorityToken,
|
|
namespaceToken: input.scope.namespaceToken,
|
|
partitionToken: input.scope.partitionToken,
|
|
hardBudgetBytes:
|
|
input.targetStoragePolicy.hardBudgetBytes,
|
|
committedBytes: 0,
|
|
reservedBytes: 0,
|
|
};
|
|
if (
|
|
budget.committedBytes +
|
|
budget.reservedBytes +
|
|
reservedBytes >
|
|
budget.hardBudgetBytes
|
|
) {
|
|
context.fail(limitExceeded("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const nextBudget: StoredBudgetRow = Object.freeze({
|
|
...budget,
|
|
reservedBytes:
|
|
budget.reservedBytes + reservedBytes,
|
|
});
|
|
watchWrite(
|
|
nativeTransaction.objectStore(BUDGET_STORE).put(nextBudget),
|
|
context,
|
|
);
|
|
const row: StoredJournalRow = Object.freeze({
|
|
...input,
|
|
logicalKey,
|
|
fencingToken,
|
|
phase: "PREPARING",
|
|
budgetReservation: Object.freeze({
|
|
namespace: input.scope.namespace,
|
|
reservedBytes,
|
|
hardBudgetBytes:
|
|
input.targetStoragePolicy.hardBudgetBytes,
|
|
}),
|
|
});
|
|
// OP-1. The `by-logical-key` index is unique, so a second open
|
|
// row for the same object is rejected here rather than by any
|
|
// predicate the journal evaluated: the ConstraintError belongs
|
|
// to this request, and it is now reported as such.
|
|
watchWrite(
|
|
nativeTransaction.objectStore(JOURNAL_STORE).add(row),
|
|
context,
|
|
);
|
|
context.succeed(row);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
async markFilesReady(
|
|
transactionId,
|
|
fencingToken,
|
|
preparedObject,
|
|
) {
|
|
if (
|
|
!SAFE_BOUNDARY_ID.test(transactionId) ||
|
|
!SAFE_BOUNDARY_ID.test(fencingToken) ||
|
|
!isPreparedObject(preparedObject) ||
|
|
!isBoundScope(preparedObject.descriptor.scope)
|
|
) {
|
|
return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE");
|
|
}
|
|
return await updateJournalRow(
|
|
transactionId,
|
|
fencingToken,
|
|
(row, context, store) => {
|
|
if (
|
|
row.mutation !== "PUT" ||
|
|
row.objectId !== preparedObject.descriptor.objectId ||
|
|
!sameScope(row.scope, preparedObject.descriptor.scope) ||
|
|
row.targetGeneration !==
|
|
preparedObject.descriptor.generation ||
|
|
row.targetByteLength !==
|
|
preparedObject.descriptor.byteLength ||
|
|
stableJson(row.targetStoragePolicy) !==
|
|
stableJson(preparedObject.descriptor.storagePolicy)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (row.phase === "FILES_READY") {
|
|
if (
|
|
!row.preparedObject ||
|
|
stableJson(row.preparedObject) !==
|
|
stableJson(preparedObject)
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
context.succeed(row);
|
|
return;
|
|
}
|
|
if (row.phase !== "PREPARING") {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const updated: StoredJournalRow = Object.freeze({
|
|
...row,
|
|
phase: "FILES_READY",
|
|
preparedObject,
|
|
});
|
|
watchWrite(store.put(updated), context);
|
|
context.succeed(updated);
|
|
},
|
|
);
|
|
},
|
|
|
|
async commitPut(transactionId, fencingToken) {
|
|
return await commitMutation(
|
|
transactionId,
|
|
fencingToken,
|
|
"PUT",
|
|
);
|
|
},
|
|
|
|
async commitDelete(transactionId, fencingToken) {
|
|
return await commitMutation(
|
|
transactionId,
|
|
fencingToken,
|
|
"DELETE",
|
|
);
|
|
},
|
|
|
|
async complete(transactionId, fencingToken) {
|
|
if (!validTransactionIdentity(transactionId, fencingToken)) {
|
|
return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE");
|
|
}
|
|
return await withDatabase("INDEXEDDB_WRITE", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[JOURNAL_STORE],
|
|
"readwrite",
|
|
"INDEXEDDB_WRITE",
|
|
(transaction, context) => {
|
|
const store = transaction.objectStore(JOURNAL_STORE);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
store.get(transactionId),
|
|
context,
|
|
(stored) => {
|
|
if (stored === undefined) {
|
|
context.succeed(undefined);
|
|
return;
|
|
}
|
|
if (
|
|
!isStoredJournalRow(stored) ||
|
|
!isBoundScope(stored.scope) ||
|
|
stored.fencingToken !== fencingToken ||
|
|
stored.phase !== "COMMITTED"
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
watchWrite(store.delete(transactionId), context);
|
|
context.succeed(undefined);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
async rollback(transactionId, fencingToken) {
|
|
if (!validTransactionIdentity(transactionId, fencingToken)) {
|
|
return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE");
|
|
}
|
|
return await withDatabase("INDEXEDDB_WRITE", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[JOURNAL_STORE, BUDGET_STORE],
|
|
"readwrite",
|
|
"INDEXEDDB_WRITE",
|
|
(transaction, context) => {
|
|
const journalStore =
|
|
transaction.objectStore(JOURNAL_STORE);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
journalStore.get(transactionId),
|
|
context,
|
|
(row) => {
|
|
if (row === undefined) {
|
|
context.succeed(undefined);
|
|
return;
|
|
}
|
|
if (
|
|
!isStoredJournalRow(row) ||
|
|
!isBoundScope(row.scope) ||
|
|
row.fencingToken !== fencingToken ||
|
|
row.phase === "COMMITTED"
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const budgetStore =
|
|
transaction.objectStore(BUDGET_STORE);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
budgetStore.get(storageBudgetKey(row.scope)),
|
|
context,
|
|
(budget) => {
|
|
if (!isBudgetRow(budget)) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (
|
|
budget.reservedBytes <
|
|
row.budgetReservation.reservedBytes
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
putOrDeleteBudget(
|
|
budgetStore,
|
|
Object.freeze({
|
|
...budget,
|
|
reservedBytes:
|
|
budget.reservedBytes -
|
|
row.budgetReservation.reservedBytes,
|
|
}),
|
|
context,
|
|
);
|
|
watchWrite(journalStore.delete(transactionId), context);
|
|
context.succeed(undefined);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
async listIncomplete(limit) {
|
|
if (
|
|
!Number.isSafeInteger(limit) ||
|
|
limit < 1 ||
|
|
limit > 1_000
|
|
) {
|
|
return browserDataFailure("INVALID_INPUT", "INDEXEDDB_READ");
|
|
}
|
|
return await withDatabase("INDEXEDDB_READ", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[JOURNAL_STORE],
|
|
"readonly",
|
|
"INDEXEDDB_READ",
|
|
(transaction, context) => {
|
|
const rows: OpfsJournalTransaction[] = [];
|
|
walkIndexedDbCursor<BrowserDataFailure>({
|
|
request: transaction
|
|
.objectStore(JOURNAL_STORE)
|
|
.index(STARTED_AT_INDEX)
|
|
.openCursor(),
|
|
sink: context,
|
|
translate: translateFor("INDEXEDDB_READ"),
|
|
// OP-4. Neither a signal nor a budget is passed: the journal
|
|
// offers no cancellation and bounds the page on `limit` alone.
|
|
visit: ({ cursor }) => {
|
|
// The row is validated before the page limit is checked, so a
|
|
// corrupt row one past the page still fails the read rather
|
|
// than being hidden behind `moreAvailable`.
|
|
if (
|
|
!isStoredJournalRow(cursor.value) ||
|
|
!isBoundScope(cursor.value.scope)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_READ"));
|
|
// `fail` aborts, so the pump has nowhere to go. Suspending
|
|
// without ever resuming says that without claiming a summary
|
|
// the scan never reached.
|
|
return { kind: "SUSPEND" };
|
|
}
|
|
if (rows.length === limit) return { kind: "STOP" };
|
|
rows.push(cursor.value);
|
|
return { kind: "CONTINUE" };
|
|
},
|
|
done: (summary) => {
|
|
context.succeed(
|
|
Object.freeze({
|
|
transactions: Object.freeze(rows),
|
|
moreAvailable:
|
|
summary.reason === "STOPPED" && rows.length === limit,
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
async listCommittedObjects(request) {
|
|
if (
|
|
!keyRange ||
|
|
!isBoundScope(request.scope) ||
|
|
!isValidOpfsStorageScope(request.scope) ||
|
|
!Number.isSafeInteger(request.limit) ||
|
|
request.limit < 1 ||
|
|
request.limit > 1_000 ||
|
|
(request.afterObjectId !== undefined &&
|
|
!SAFE_BOUNDARY_ID.test(request.afterObjectId))
|
|
) {
|
|
return browserDataFailure(
|
|
keyRange ? "INVALID_INPUT" : "UNSUPPORTED",
|
|
"INDEXEDDB_READ",
|
|
);
|
|
}
|
|
return await withDatabase("INDEXEDDB_READ", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[OBJECT_STORE],
|
|
"readonly",
|
|
"INDEXEDDB_READ",
|
|
(transaction, context) => {
|
|
const prefix = `${storageScopeKey(request.scope)}|`;
|
|
const lower = request.afterObjectId
|
|
? `${prefix}${request.afterObjectId}`
|
|
: prefix;
|
|
const range = keyRange.bound(
|
|
lower,
|
|
`${prefix}\uffff`,
|
|
request.afterObjectId !== undefined,
|
|
false,
|
|
);
|
|
const objects: OpfsPreparedObject[] = [];
|
|
walkIndexedDbCursor<BrowserDataFailure>({
|
|
request: transaction
|
|
.objectStore(OBJECT_STORE)
|
|
.index(SCOPE_OBJECT_INDEX)
|
|
.openCursor(range),
|
|
sink: context,
|
|
translate: translateFor("INDEXEDDB_READ"),
|
|
visit: ({ cursor }) => {
|
|
if (
|
|
!isStoredObjectRow(cursor.value) ||
|
|
!sameScope(
|
|
cursor.value.preparedObject.descriptor.scope,
|
|
request.scope,
|
|
)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_READ"));
|
|
return { kind: "SUSPEND" };
|
|
}
|
|
if (objects.length === request.limit) {
|
|
return { kind: "STOP" };
|
|
}
|
|
objects.push(cursor.value.preparedObject);
|
|
return { kind: "CONTINUE" };
|
|
},
|
|
done: (summary) => {
|
|
// A page cut short by the limit hands back the cursor the
|
|
// caller resumes from; an exhausted scan has nothing to resume.
|
|
const more =
|
|
summary.reason === "STOPPED" &&
|
|
objects.length === request.limit;
|
|
context.succeed(
|
|
Object.freeze({
|
|
objects: Object.freeze(objects),
|
|
nextObjectId: more
|
|
? objects.at(-1)?.descriptor.objectId ?? null
|
|
: null,
|
|
moreAvailable: more,
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
async isChunkReferenced(scope, digestHex) {
|
|
if (
|
|
!isBoundScope(scope) ||
|
|
!isValidOpfsStorageScope(scope) ||
|
|
!SHA256_HEX.test(digestHex)
|
|
) {
|
|
return browserDataFailure("INVALID_INPUT", "INDEXEDDB_READ");
|
|
}
|
|
const referenceKey = chunkReferenceKey(scope, digestHex);
|
|
return await withDatabase("INDEXEDDB_READ", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[CHUNK_REFERENCE_STORE],
|
|
"readonly",
|
|
"INDEXEDDB_READ",
|
|
(transaction, context) => {
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
transaction
|
|
.objectStore(CHUNK_REFERENCE_STORE)
|
|
.get(referenceKey),
|
|
context,
|
|
(stored) => {
|
|
if (stored === undefined) {
|
|
context.succeed(false);
|
|
return;
|
|
}
|
|
if (
|
|
!isChunkReference(stored) ||
|
|
stored.referenceKey !== referenceKey ||
|
|
stored.scopeKey !== storageScopeKey(scope)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_READ"));
|
|
return;
|
|
}
|
|
context.succeed(stored.referenceCount > 0);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
close() {
|
|
connection.close();
|
|
},
|
|
};
|
|
|
|
return Object.freeze(journal);
|
|
|
|
async function commitMutation(
|
|
transactionId: string,
|
|
fencingToken: string,
|
|
mutation: "PUT" | "DELETE",
|
|
): Promise<BrowserDataResult<OpfsJournalTransaction>> {
|
|
if (!validTransactionIdentity(transactionId, fencingToken)) {
|
|
return browserDataFailure("INVALID_INPUT", "INDEXEDDB_WRITE");
|
|
}
|
|
return await withDatabase("INDEXEDDB_WRITE", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[
|
|
JOURNAL_STORE,
|
|
OBJECT_STORE,
|
|
BUDGET_STORE,
|
|
CHUNK_REFERENCE_STORE,
|
|
],
|
|
"readwrite",
|
|
"INDEXEDDB_WRITE",
|
|
(nativeTransaction, context) => {
|
|
const journalStore =
|
|
nativeTransaction.objectStore(JOURNAL_STORE);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
journalStore.get(transactionId),
|
|
context,
|
|
(row) => {
|
|
if (
|
|
!isStoredJournalRow(row) ||
|
|
!isBoundScope(row.scope) ||
|
|
row.fencingToken !== fencingToken ||
|
|
row.mutation !== mutation
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (row.phase === "COMMITTED") {
|
|
context.succeed(row);
|
|
return;
|
|
}
|
|
if (
|
|
mutation === "PUT" &&
|
|
(row.phase !== "FILES_READY" || !row.preparedObject)
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (mutation === "DELETE" && row.phase !== "PREPARING") {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
|
|
const objectStore =
|
|
nativeTransaction.objectStore(OBJECT_STORE);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
objectStore.get(row.logicalKey),
|
|
context,
|
|
(storedCurrent) => {
|
|
if (
|
|
storedCurrent !== undefined &&
|
|
!isStoredObjectRow(storedCurrent)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const current: OpfsPreparedObject | undefined =
|
|
storedCurrent?.preparedObject;
|
|
if (
|
|
!generationMatches(current, row.expectedGeneration)
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const budgetStore =
|
|
nativeTransaction.objectStore(BUDGET_STORE);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
budgetStore.get(storageBudgetKey(row.scope)),
|
|
context,
|
|
(budget) => {
|
|
if (!isBudgetRow(budget)) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const currentBytes =
|
|
current?.descriptor.byteLength ?? 0;
|
|
const nextBytes =
|
|
mutation === "PUT" ? row.targetByteLength : 0;
|
|
const nextCommitted =
|
|
budget.committedBytes - currentBytes + nextBytes;
|
|
const nextReserved =
|
|
budget.reservedBytes -
|
|
row.budgetReservation.reservedBytes;
|
|
if (
|
|
nextCommitted < 0 ||
|
|
nextReserved < 0 ||
|
|
nextCommitted + nextReserved >
|
|
budget.hardBudgetBytes
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
|
|
const referenceDeltas = chunkReferenceDeltas(
|
|
current,
|
|
mutation === "PUT" ? row.preparedObject : undefined,
|
|
);
|
|
applyChunkReferenceDeltas(
|
|
nativeTransaction.objectStore(
|
|
CHUNK_REFERENCE_STORE,
|
|
),
|
|
row.scope,
|
|
referenceDeltas,
|
|
context,
|
|
() => {
|
|
putOrDeleteBudget(
|
|
budgetStore,
|
|
Object.freeze({
|
|
...budget,
|
|
committedBytes: nextCommitted,
|
|
reservedBytes: nextReserved,
|
|
}),
|
|
context,
|
|
);
|
|
if (mutation === "PUT") {
|
|
watchWrite(
|
|
objectStore.put(
|
|
storedObjectRow(row.preparedObject!),
|
|
),
|
|
context,
|
|
);
|
|
} else {
|
|
watchWrite(
|
|
objectStore.delete(row.logicalKey),
|
|
context,
|
|
);
|
|
}
|
|
const committed: StoredJournalRow = Object.freeze({
|
|
...row,
|
|
phase: "COMMITTED",
|
|
});
|
|
watchWrite(journalStore.put(committed), context);
|
|
context.succeed(committed);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
async function updateJournalRow(
|
|
transactionId: string,
|
|
fencingToken: string,
|
|
update: (
|
|
row: StoredJournalRow,
|
|
context: TransactionContext<OpfsJournalTransaction>,
|
|
store: IDBObjectStore,
|
|
) => void,
|
|
): Promise<BrowserDataResult<OpfsJournalTransaction>> {
|
|
return await withDatabase("INDEXEDDB_WRITE", (db) =>
|
|
runTransaction(
|
|
db,
|
|
[JOURNAL_STORE],
|
|
"readwrite",
|
|
"INDEXEDDB_WRITE",
|
|
(transaction, context) => {
|
|
const store = transaction.objectStore(JOURNAL_STORE);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
store.get(transactionId),
|
|
context,
|
|
(stored) => {
|
|
if (
|
|
!isStoredJournalRow(stored) ||
|
|
!isBoundScope(stored.scope) ||
|
|
stored.fencingToken !== fencingToken
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
update(stored, context, store);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
async function withDatabase<Value>(
|
|
operation: BrowserDataOperation,
|
|
task: (db: IDBDatabase) => Promise<BrowserDataResult<Value>>,
|
|
): Promise<BrowserDataResult<Value>> {
|
|
const opened = await connection.acquire();
|
|
if (!opened.ok) return opened;
|
|
try {
|
|
const result = await task(opened.value);
|
|
observe(result, operation);
|
|
return result;
|
|
} catch (error) {
|
|
const result = mapIndexedDbException(error, operation);
|
|
observe(result, operation);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
function observe(
|
|
result: BrowserDataResult<unknown>,
|
|
operation: BrowserDataOperation,
|
|
): void {
|
|
try {
|
|
dependencies.observe?.(
|
|
result.ok
|
|
? { operation, outcome: "SUCCEEDED" }
|
|
: {
|
|
operation,
|
|
outcome: "FAILED",
|
|
failureCode: result.error.code,
|
|
},
|
|
);
|
|
} catch {
|
|
// Journal behavior never depends on telemetry.
|
|
}
|
|
}
|
|
|
|
function isBoundScope(scope: OpfsStorageScope): boolean {
|
|
return scope.authorityToken === dependencies.authorityToken;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every readwrite transaction is strict because the journal is the durable
|
|
* record of a two-phase OPFS mutation: a write that is only queued when the
|
|
* tab goes away would leave files on disk that no journal row claims. Reads
|
|
* pass `undefined`, which opens with no options bag at all rather than with
|
|
* `{durability:"default"}` — the form this file has always used.
|
|
*/
|
|
function runTransaction<Value>(
|
|
database: IDBDatabase,
|
|
storeNames: readonly string[],
|
|
mode: IDBTransactionMode,
|
|
operation: BrowserDataOperation,
|
|
run: (
|
|
transaction: IDBTransaction,
|
|
context: TransactionContext<Value>,
|
|
) => void,
|
|
): Promise<BrowserDataResult<Value>> {
|
|
if (mode === "versionchange") {
|
|
// The journal only ever opens readonly or readwrite transactions; a
|
|
// versionchange transaction belongs to the open request, not here.
|
|
return Promise.resolve(
|
|
browserDataFailure("INVALID_INPUT", operation),
|
|
);
|
|
}
|
|
return runIndexedDbTransaction<Value, BrowserDataFailure>({
|
|
database,
|
|
stores: storeNames,
|
|
mode,
|
|
translate: translateFor(operation),
|
|
durability: mode === "readwrite" ? "strict" : undefined,
|
|
queue: run,
|
|
});
|
|
}
|
|
|
|
function applyChunkReferenceDeltas(
|
|
store: IDBObjectStore,
|
|
scope: OpfsStorageScope,
|
|
deltas: ReadonlyMap<string, number>,
|
|
context: TransactionContext<OpfsJournalTransaction>,
|
|
completed: () => void,
|
|
): void {
|
|
const entries = [...deltas].filter(([, delta]) => delta !== 0);
|
|
if (entries.length === 0) {
|
|
completed();
|
|
return;
|
|
}
|
|
let remaining = entries.length;
|
|
for (const [digestHex, delta] of entries) {
|
|
const referenceKey = chunkReferenceKey(scope, digestHex);
|
|
onIndexedDbRequest<unknown, BrowserDataFailure>(
|
|
store.get(referenceKey),
|
|
context,
|
|
(stored) => {
|
|
if (
|
|
stored !== undefined &&
|
|
(!isChunkReference(stored) ||
|
|
stored.referenceKey !== referenceKey ||
|
|
stored.scopeKey !== storageScopeKey(scope))
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const current =
|
|
(stored as StoredChunkReference | undefined)?.referenceCount ?? 0;
|
|
const next = current + delta;
|
|
if (!Number.isSafeInteger(next) || next < 0) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (next === 0) {
|
|
watchWrite(store.delete(referenceKey), context);
|
|
} else {
|
|
watchWrite(
|
|
store.put(
|
|
Object.freeze({
|
|
referenceKey,
|
|
scopeKey: storageScopeKey(scope),
|
|
digestHex,
|
|
referenceCount: next,
|
|
}),
|
|
),
|
|
context,
|
|
);
|
|
}
|
|
remaining -= 1;
|
|
if (remaining === 0) completed();
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
function chunkReferenceDeltas(
|
|
previous: OpfsPreparedObject | undefined,
|
|
next: OpfsPreparedObject | undefined,
|
|
): ReadonlyMap<string, number> {
|
|
const deltas = new Map<string, number>();
|
|
for (const chunk of previous?.chunks ?? []) {
|
|
deltas.set(chunk.digestHex, (deltas.get(chunk.digestHex) ?? 0) - 1);
|
|
}
|
|
for (const chunk of next?.chunks ?? []) {
|
|
deltas.set(chunk.digestHex, (deltas.get(chunk.digestHex) ?? 0) + 1);
|
|
}
|
|
return deltas;
|
|
}
|
|
|
|
function putOrDeleteBudget(
|
|
store: IDBObjectStore,
|
|
budget: StoredBudgetRow,
|
|
sink: IndexedDbRequestSink<BrowserDataFailure>,
|
|
): void {
|
|
// Separate calls rather than one over a ternary: `IDBRequest` is invariant
|
|
// in its result through the `this` type of its handlers, so the two request
|
|
// types have no useful union.
|
|
if (budget.committedBytes === 0 && budget.reservedBytes === 0) {
|
|
watchWrite(store.delete(budget.budgetKey), sink);
|
|
return;
|
|
}
|
|
watchWrite(store.put(budget), sink);
|
|
}
|
|
|
|
function storedObjectRow(
|
|
preparedObject: OpfsPreparedObject,
|
|
): StoredObjectRow {
|
|
const { scope, objectId } = preparedObject.descriptor;
|
|
return Object.freeze({
|
|
logicalKey: logicalObjectKey(scope, objectId),
|
|
scopeObjectKey: `${storageScopeKey(scope)}|${objectId}`,
|
|
scopeKey: storageScopeKey(scope),
|
|
objectId,
|
|
preparedObject,
|
|
});
|
|
}
|
|
|
|
function generationMatches(
|
|
current: OpfsPreparedObject | undefined,
|
|
expectedGeneration: number | null,
|
|
): boolean {
|
|
return expectedGeneration === null
|
|
? current === undefined
|
|
: current?.descriptor.generation === expectedGeneration;
|
|
}
|
|
|
|
function logicalObjectKey(
|
|
scope: OpfsStorageScope,
|
|
objectId: string,
|
|
): string {
|
|
return `${storageScopeKey(scope)}|${objectId}`;
|
|
}
|
|
|
|
function storageScopeKey(scope: OpfsStorageScope): string {
|
|
return [
|
|
scope.authorityToken,
|
|
scope.namespaceToken,
|
|
scope.partitionToken,
|
|
].join("|");
|
|
}
|
|
|
|
function storageBudgetKey(scope: OpfsStorageScope): string {
|
|
return storageScopeKey(scope);
|
|
}
|
|
|
|
function scopeBinding(
|
|
scope: OpfsStorageScope,
|
|
storagePolicy: BrowserStoragePolicy,
|
|
): StoredScopeBinding {
|
|
return Object.freeze({
|
|
scopeKey: storageScopeKey(scope),
|
|
namespace: scope.namespace,
|
|
authorityToken: scope.authorityToken,
|
|
namespaceToken: scope.namespaceToken,
|
|
partitionToken: scope.partitionToken,
|
|
policyFingerprint: stableJson(storagePolicy),
|
|
});
|
|
}
|
|
|
|
function logicalScopeBinding(
|
|
scope: OpfsStorageScope,
|
|
): StoredLogicalScopeBinding {
|
|
return Object.freeze({
|
|
logicalScopeKey: [
|
|
scope.authorityToken,
|
|
scope.namespace,
|
|
scope.partitionToken,
|
|
].join("|"),
|
|
physicalScopeKey: storageScopeKey(scope),
|
|
authorityToken: scope.authorityToken,
|
|
namespace: scope.namespace,
|
|
namespaceToken: scope.namespaceToken,
|
|
partitionToken: scope.partitionToken,
|
|
});
|
|
}
|
|
|
|
function chunkReferenceKey(
|
|
scope: OpfsStorageScope,
|
|
digestHex: string,
|
|
): string {
|
|
return `${storageScopeKey(scope)}|${digestHex}`;
|
|
}
|
|
|
|
function sameScope(
|
|
left: OpfsStorageScope,
|
|
right: OpfsStorageScope,
|
|
): boolean {
|
|
return (
|
|
left.namespace === right.namespace &&
|
|
left.authorityToken === right.authorityToken &&
|
|
left.namespaceToken === right.namespaceToken &&
|
|
left.partitionToken === right.partitionToken
|
|
);
|
|
}
|
|
|
|
function validScopedObject(
|
|
scope: OpfsStorageScope,
|
|
objectId: string,
|
|
): boolean {
|
|
return (
|
|
isValidOpfsStorageScope(scope) &&
|
|
SAFE_BOUNDARY_ID.test(objectId)
|
|
);
|
|
}
|
|
|
|
function validTransactionIdentity(
|
|
transactionId: string,
|
|
fencingToken: string,
|
|
): boolean {
|
|
return (
|
|
SAFE_BOUNDARY_ID.test(transactionId) &&
|
|
SAFE_BOUNDARY_ID.test(fencingToken)
|
|
);
|
|
}
|
|
|
|
function isBeginTransaction(
|
|
value: BeginOpfsJournalTransaction,
|
|
): boolean {
|
|
try {
|
|
assertValidStoragePolicy(value.targetStoragePolicy);
|
|
} catch {
|
|
return false;
|
|
}
|
|
return Boolean(
|
|
validScopedObject(value.scope, value.objectId) &&
|
|
SAFE_BOUNDARY_ID.test(value.transactionId) &&
|
|
value.targetStoragePolicy.namespace === value.scope.namespace &&
|
|
(value.mutation === "PUT" || value.mutation === "DELETE") &&
|
|
(value.expectedGeneration === null ||
|
|
(Number.isSafeInteger(value.expectedGeneration) &&
|
|
value.expectedGeneration > 0)) &&
|
|
Number.isSafeInteger(value.targetGeneration) &&
|
|
value.targetGeneration > 0 &&
|
|
Number.isSafeInteger(value.targetByteLength) &&
|
|
value.targetByteLength >= 0 &&
|
|
(value.mutation === "PUT" || value.targetByteLength === 0) &&
|
|
Number.isSafeInteger(value.startedAtEpochMs) &&
|
|
value.startedAtEpochMs >= 0,
|
|
);
|
|
}
|
|
|
|
function isStoredJournalRow(
|
|
value: unknown,
|
|
): value is StoredJournalRow {
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
!("logicalKey" in value) ||
|
|
typeof value.logicalKey !== "string" ||
|
|
!("transactionId" in value) ||
|
|
typeof value.transactionId !== "string" ||
|
|
!SAFE_BOUNDARY_ID.test(value.transactionId) ||
|
|
!("fencingToken" in value) ||
|
|
typeof value.fencingToken !== "string" ||
|
|
!SAFE_BOUNDARY_ID.test(value.fencingToken) ||
|
|
!("scope" in value) ||
|
|
!value.scope ||
|
|
typeof value.scope !== "object" ||
|
|
!isValidOpfsStorageScope(value.scope as OpfsStorageScope) ||
|
|
!("mutation" in value) ||
|
|
(value.mutation !== "PUT" && value.mutation !== "DELETE") ||
|
|
!("phase" in value) ||
|
|
!["PREPARING", "FILES_READY", "COMMITTED"].includes(
|
|
String(value.phase),
|
|
) ||
|
|
!("objectId" in value) ||
|
|
typeof value.objectId !== "string" ||
|
|
!SAFE_BOUNDARY_ID.test(value.objectId) ||
|
|
value.logicalKey !==
|
|
logicalObjectKey(value.scope as OpfsStorageScope, value.objectId) ||
|
|
!("expectedGeneration" in value) ||
|
|
(value.expectedGeneration !== null &&
|
|
(typeof value.expectedGeneration !== "number" ||
|
|
!Number.isSafeInteger(value.expectedGeneration) ||
|
|
value.expectedGeneration < 1)) ||
|
|
!("targetGeneration" in value) ||
|
|
typeof value.targetGeneration !== "number" ||
|
|
!Number.isSafeInteger(value.targetGeneration) ||
|
|
value.targetGeneration < 1 ||
|
|
!("targetByteLength" in value) ||
|
|
typeof value.targetByteLength !== "number" ||
|
|
!Number.isSafeInteger(value.targetByteLength) ||
|
|
value.targetByteLength < 0 ||
|
|
!("targetStoragePolicy" in value) ||
|
|
!value.targetStoragePolicy ||
|
|
typeof value.targetStoragePolicy !== "object" ||
|
|
!("budgetReservation" in value) ||
|
|
!isBudgetReservation(value.budgetReservation) ||
|
|
!("startedAtEpochMs" in value) ||
|
|
typeof value.startedAtEpochMs !== "number" ||
|
|
!Number.isSafeInteger(value.startedAtEpochMs) ||
|
|
value.startedAtEpochMs < 0
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
assertValidStoragePolicy(
|
|
value.targetStoragePolicy as OpfsJournalTransaction["targetStoragePolicy"],
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const scope = value.scope as OpfsStorageScope;
|
|
const storagePolicy =
|
|
value.targetStoragePolicy as OpfsJournalTransaction["targetStoragePolicy"];
|
|
const budgetReservation =
|
|
value.budgetReservation as OpfsJournalTransaction["budgetReservation"];
|
|
if (
|
|
storagePolicy.namespace !== scope.namespace ||
|
|
budgetReservation.namespace !== scope.namespace ||
|
|
budgetReservation.hardBudgetBytes !==
|
|
storagePolicy.hardBudgetBytes
|
|
) {
|
|
return false;
|
|
}
|
|
if ("preparedObject" in value && value.preparedObject !== undefined) {
|
|
return (
|
|
value.mutation === "PUT" &&
|
|
value.phase !== "PREPARING" &&
|
|
isPreparedObject(value.preparedObject) &&
|
|
value.preparedObject.descriptor.objectId === value.objectId &&
|
|
sameScope(
|
|
value.preparedObject.descriptor.scope,
|
|
value.scope as OpfsStorageScope,
|
|
)
|
|
);
|
|
}
|
|
return (
|
|
value.phase === "PREPARING" ||
|
|
(value.phase === "COMMITTED" && value.mutation === "DELETE")
|
|
);
|
|
}
|
|
|
|
function isBudgetReservation(value: unknown): boolean {
|
|
return Boolean(
|
|
value &&
|
|
typeof value === "object" &&
|
|
"namespace" in value &&
|
|
typeof value.namespace === "string" &&
|
|
"reservedBytes" in value &&
|
|
typeof value.reservedBytes === "number" &&
|
|
Number.isSafeInteger(value.reservedBytes) &&
|
|
value.reservedBytes >= 0 &&
|
|
"hardBudgetBytes" in value &&
|
|
typeof value.hardBudgetBytes === "number" &&
|
|
Number.isSafeInteger(value.hardBudgetBytes) &&
|
|
value.hardBudgetBytes >= 0,
|
|
);
|
|
}
|
|
|
|
function isStoredObjectRow(value: unknown): value is StoredObjectRow {
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
!("logicalKey" in value) ||
|
|
typeof value.logicalKey !== "string" ||
|
|
!("scopeObjectKey" in value) ||
|
|
typeof value.scopeObjectKey !== "string" ||
|
|
!("scopeKey" in value) ||
|
|
typeof value.scopeKey !== "string" ||
|
|
!("objectId" in value) ||
|
|
typeof value.objectId !== "string" ||
|
|
!("preparedObject" in value) ||
|
|
!isPreparedObject(value.preparedObject)
|
|
) {
|
|
return false;
|
|
}
|
|
const scope = value.preparedObject.descriptor.scope;
|
|
return (
|
|
value.objectId === value.preparedObject.descriptor.objectId &&
|
|
value.logicalKey === logicalObjectKey(scope, value.objectId) &&
|
|
value.scopeKey === storageScopeKey(scope) &&
|
|
value.scopeObjectKey === `${value.scopeKey}|${value.objectId}`
|
|
);
|
|
}
|
|
|
|
function isBudgetRow(value: unknown): value is StoredBudgetRow {
|
|
return Boolean(
|
|
value &&
|
|
typeof value === "object" &&
|
|
"budgetKey" in value &&
|
|
typeof value.budgetKey === "string" &&
|
|
"namespace" in value &&
|
|
typeof value.namespace === "string" &&
|
|
"authorityToken" in value &&
|
|
typeof value.authorityToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.authorityToken) &&
|
|
"namespaceToken" in value &&
|
|
typeof value.namespaceToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.namespaceToken) &&
|
|
"partitionToken" in value &&
|
|
typeof value.partitionToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.partitionToken) &&
|
|
value.budgetKey ===
|
|
`${value.authorityToken}|${value.namespaceToken}|${value.partitionToken}` &&
|
|
"hardBudgetBytes" in value &&
|
|
typeof value.hardBudgetBytes === "number" &&
|
|
Number.isSafeInteger(value.hardBudgetBytes) &&
|
|
value.hardBudgetBytes >= 0 &&
|
|
"committedBytes" in value &&
|
|
typeof value.committedBytes === "number" &&
|
|
Number.isSafeInteger(value.committedBytes) &&
|
|
value.committedBytes >= 0 &&
|
|
"reservedBytes" in value &&
|
|
typeof value.reservedBytes === "number" &&
|
|
Number.isSafeInteger(value.reservedBytes) &&
|
|
value.reservedBytes >= 0 &&
|
|
value.committedBytes + value.reservedBytes <=
|
|
value.hardBudgetBytes,
|
|
);
|
|
}
|
|
|
|
function isScopeBinding(value: unknown): value is StoredScopeBinding {
|
|
return Boolean(
|
|
value &&
|
|
typeof value === "object" &&
|
|
"scopeKey" in value &&
|
|
typeof value.scopeKey === "string" &&
|
|
"namespace" in value &&
|
|
typeof value.namespace === "string" &&
|
|
"authorityToken" in value &&
|
|
typeof value.authorityToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.authorityToken) &&
|
|
"namespaceToken" in value &&
|
|
typeof value.namespaceToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.namespaceToken) &&
|
|
"partitionToken" in value &&
|
|
typeof value.partitionToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.partitionToken) &&
|
|
value.scopeKey ===
|
|
`${value.authorityToken}|${value.namespaceToken}|${value.partitionToken}` &&
|
|
"policyFingerprint" in value &&
|
|
typeof value.policyFingerprint === "string" &&
|
|
value.policyFingerprint.length > 0 &&
|
|
value.policyFingerprint.length <= 4_096,
|
|
);
|
|
}
|
|
|
|
function isLogicalScopeBinding(
|
|
value: unknown,
|
|
): value is StoredLogicalScopeBinding {
|
|
return Boolean(
|
|
value &&
|
|
typeof value === "object" &&
|
|
"logicalScopeKey" in value &&
|
|
typeof value.logicalScopeKey === "string" &&
|
|
"physicalScopeKey" in value &&
|
|
typeof value.physicalScopeKey === "string" &&
|
|
"authorityToken" in value &&
|
|
typeof value.authorityToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.authorityToken) &&
|
|
"namespace" in value &&
|
|
typeof value.namespace === "string" &&
|
|
"namespaceToken" in value &&
|
|
typeof value.namespaceToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.namespaceToken) &&
|
|
"partitionToken" in value &&
|
|
typeof value.partitionToken === "string" &&
|
|
SAFE_BOUNDARY_ID.test(value.partitionToken) &&
|
|
value.logicalScopeKey ===
|
|
`${value.authorityToken}|${value.namespace}|${value.partitionToken}` &&
|
|
value.physicalScopeKey ===
|
|
`${value.authorityToken}|${value.namespaceToken}|${value.partitionToken}`,
|
|
);
|
|
}
|
|
|
|
function isChunkReference(
|
|
value: unknown,
|
|
): value is StoredChunkReference {
|
|
return Boolean(
|
|
value &&
|
|
typeof value === "object" &&
|
|
"referenceKey" in value &&
|
|
typeof value.referenceKey === "string" &&
|
|
"scopeKey" in value &&
|
|
typeof value.scopeKey === "string" &&
|
|
"digestHex" in value &&
|
|
typeof value.digestHex === "string" &&
|
|
SHA256_HEX.test(value.digestHex) &&
|
|
value.referenceKey ===
|
|
`${value.scopeKey}|${value.digestHex}` &&
|
|
"referenceCount" in value &&
|
|
typeof value.referenceCount === "number" &&
|
|
Number.isSafeInteger(value.referenceCount) &&
|
|
value.referenceCount > 0,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* STO-01 expand phase. v1 prepared objects stay readable through the rollback
|
|
* window; v2 additionally carries a transaction-unique physical fencing token.
|
|
*/
|
|
function isSupportedPhysicalSchema(value: object): boolean {
|
|
const record = value as Record<string, unknown>;
|
|
if (record.physicalSchemaVersion === 1) return true;
|
|
return (
|
|
record.physicalSchemaVersion === 2 &&
|
|
typeof record.physicalGenerationId === "string" &&
|
|
/^[0-9a-f]{32}$/u.test(record.physicalGenerationId)
|
|
);
|
|
}
|
|
|
|
function isPreparedObject(value: unknown): value is OpfsPreparedObject {
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
!("physicalSchemaVersion" in value) ||
|
|
!isSupportedPhysicalSchema(value) ||
|
|
!("descriptor" in value) ||
|
|
!value.descriptor ||
|
|
typeof value.descriptor !== "object" ||
|
|
!("chunks" in value) ||
|
|
!Array.isArray(value.chunks)
|
|
) {
|
|
return false;
|
|
}
|
|
const descriptor = value.descriptor as Record<string, unknown>;
|
|
const integrity =
|
|
descriptor.integrity && typeof descriptor.integrity === "object"
|
|
? (descriptor.integrity as Record<string, unknown>)
|
|
: null;
|
|
if (
|
|
typeof descriptor.objectId !== "string" ||
|
|
!SAFE_BOUNDARY_ID.test(descriptor.objectId) ||
|
|
!descriptor.scope ||
|
|
typeof descriptor.scope !== "object" ||
|
|
!isValidOpfsStorageScope(descriptor.scope as OpfsStorageScope) ||
|
|
typeof descriptor.generation !== "number" ||
|
|
!Number.isSafeInteger(descriptor.generation) ||
|
|
descriptor.generation < 1 ||
|
|
typeof descriptor.byteLength !== "number" ||
|
|
!Number.isSafeInteger(descriptor.byteLength) ||
|
|
descriptor.byteLength < 0 ||
|
|
typeof descriptor.mediaType !== "string" ||
|
|
descriptor.mediaType.length < 1 ||
|
|
typeof descriptor.createdAtEpochMs !== "number" ||
|
|
!Number.isSafeInteger(descriptor.createdAtEpochMs) ||
|
|
descriptor.createdAtEpochMs < 0 ||
|
|
!integrity ||
|
|
integrity.algorithm !== "SHA-256-TREE-V1" ||
|
|
typeof integrity.rootDigestHex !== "string" ||
|
|
!SHA256_HEX.test(integrity.rootDigestHex) ||
|
|
typeof integrity.chunkSizeBytes !== "number" ||
|
|
!Number.isSafeInteger(integrity.chunkSizeBytes) ||
|
|
integrity.chunkSizeBytes < 1 ||
|
|
!descriptor.storagePolicy ||
|
|
typeof descriptor.storagePolicy !== "object"
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
assertValidStoragePolicy(
|
|
descriptor.storagePolicy as OpfsPreparedObject["descriptor"]["storagePolicy"],
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (
|
|
(descriptor.scope as OpfsStorageScope).namespace !==
|
|
(
|
|
descriptor.storagePolicy as OpfsPreparedObject["descriptor"]["storagePolicy"]
|
|
).namespace
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
let totalBytes = 0;
|
|
for (let index = 0; index < value.chunks.length; index += 1) {
|
|
const chunk = value.chunks[index] as unknown;
|
|
if (
|
|
!chunk ||
|
|
typeof chunk !== "object" ||
|
|
!("sequence" in chunk) ||
|
|
chunk.sequence !== index ||
|
|
!("byteLength" in chunk) ||
|
|
typeof chunk.byteLength !== "number" ||
|
|
!Number.isSafeInteger(chunk.byteLength) ||
|
|
chunk.byteLength < 1 ||
|
|
chunk.byteLength > integrity.chunkSizeBytes ||
|
|
!("digestHex" in chunk) ||
|
|
typeof chunk.digestHex !== "string" ||
|
|
!SHA256_HEX.test(chunk.digestHex)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
index < value.chunks.length - 1 &&
|
|
chunk.byteLength !== integrity.chunkSizeBytes
|
|
) {
|
|
return false;
|
|
}
|
|
totalBytes += chunk.byteLength;
|
|
}
|
|
return (
|
|
totalBytes === descriptor.byteLength &&
|
|
value.chunks.length ===
|
|
Math.ceil(descriptor.byteLength / integrity.chunkSizeBytes)
|
|
);
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (
|
|
value === null ||
|
|
typeof value === "string" ||
|
|
typeof value === "boolean" ||
|
|
typeof value === "number"
|
|
) {
|
|
return JSON.stringify(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(stableJson).join(",")}]`;
|
|
}
|
|
if (typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
return `{${Object.keys(record)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
|
.join(",")}}`;
|
|
}
|
|
throw new TypeError("Journal value is not JSON-safe.");
|
|
}
|
|
|
|
/**
|
|
* `browserDataFailure` and `mapIndexedDbException` build a `Result`, while the
|
|
* kernel's `translate` and `context.fail` want the failure on its own. Both
|
|
* only ever build the failure arm, so the branch below narrows rather than
|
|
* claims.
|
|
*/
|
|
function failureOf(result: BrowserDataResult<never>): BrowserDataFailure {
|
|
if (result.ok) {
|
|
throw new TypeError("A browser data failure was expected.");
|
|
}
|
|
return result.error;
|
|
}
|
|
|
|
/**
|
|
* A write nobody waits on still has to report its own failure. This file wired
|
|
* no request `onerror` at all before the kernel, so a unique-index
|
|
* `ConstraintError` reached the caller only as whatever `transaction.error`
|
|
* happened to hold once the store unwound — a provenance the journal never
|
|
* chose and cannot rely on.
|
|
*
|
|
* `requestFailed` records without aborting, which is deliberate: the store
|
|
* already aborts a transaction whose request error goes unhandled, and calling
|
|
* `fail` here would report the journal as the party that rejected a write the
|
|
* index rejected.
|
|
*/
|
|
function watchWrite<Value>(
|
|
request: IDBRequest<Value>,
|
|
sink: IndexedDbRequestSink<BrowserDataFailure>,
|
|
): void {
|
|
onIndexedDbRequest(request, sink, () => {
|
|
// A write's own success publishes nothing; the transaction's completion
|
|
// does.
|
|
});
|
|
}
|
|
|
|
function conflict(operation: BrowserDataOperation): BrowserDataFailure {
|
|
return failureOf(
|
|
browserDataFailure("CONFLICT", operation, { recovery: "REOPEN" }),
|
|
);
|
|
}
|
|
|
|
function corrupt(operation: BrowserDataOperation): BrowserDataFailure {
|
|
return failureOf(
|
|
browserDataFailure("CORRUPT_DATA", operation, { recovery: "READ_ONLY" }),
|
|
);
|
|
}
|
|
|
|
function policyRejected(
|
|
operation: BrowserDataOperation,
|
|
): BrowserDataFailure {
|
|
return failureOf(
|
|
browserDataFailure("POLICY_REJECTED", operation, {
|
|
recovery: "READ_ONLY",
|
|
}),
|
|
);
|
|
}
|
|
|
|
function limitExceeded(
|
|
operation: BrowserDataOperation,
|
|
): BrowserDataFailure {
|
|
return failureOf(
|
|
browserDataFailure("LIMIT_EXCEEDED", operation, {
|
|
recovery: "EXPORT_REQUIRED",
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* STO-01. The journal keeps `mapIndexedDbException`, which the IndexedDB
|
|
* runtime and maintenance share, rather than the checkpoint store's own table:
|
|
* the same native error is a different answer in the two families and unifying
|
|
* them is a separate change from moving the mechanics onto the kernel.
|
|
*
|
|
* A translator per operation, because the journal labels a read and a write
|
|
* differently. The connection always translates as `INDEXEDDB_OPEN`: an open
|
|
* failure names the operation it belongs to, not the call that happened to ask
|
|
* for the connection, and that is why `withDatabase` returns it unobserved.
|
|
*/
|
|
function translateFor(
|
|
operation: BrowserDataOperation,
|
|
): IndexedDbTranslate<BrowserDataFailure> {
|
|
return (cause) => {
|
|
switch (cause.kind) {
|
|
case "NATIVE_EXCEPTION":
|
|
return failureOf(mapIndexedDbException(cause.error, operation));
|
|
case "BLOCKED":
|
|
case "BLOCKED_DEADLINE":
|
|
// The journal reports the same answer whether the blocked event is
|
|
// itself terminal or a deadline ran out: a `blockedTimeoutMs` of 0 is
|
|
// a valid configuration here, and it must not report a different code
|
|
// from the same standing conflict.
|
|
return failureOf(
|
|
browserDataFailure("BLOCKED", operation, {
|
|
retryable: true,
|
|
recovery: "RELOAD_OTHER_CONTEXTS",
|
|
}),
|
|
);
|
|
case "CLOSED":
|
|
case "UNSUPPORTED":
|
|
// A closed journal and a realm without IndexedDB are the same answer
|
|
// to the caller: this store cannot serve the request at all, so work
|
|
// online instead of retrying.
|
|
return failureOf(
|
|
browserDataFailure("UNSUPPORTED", operation, {
|
|
recovery: "ONLINE_ONLY",
|
|
}),
|
|
);
|
|
case "NO_VALUE_PRODUCED":
|
|
return failureOf(
|
|
browserDataFailure("UNAVAILABLE", operation, {
|
|
retryable: true,
|
|
recovery: "REOPEN",
|
|
}),
|
|
);
|
|
case "UPGRADE_REJECTED":
|
|
case "CALLER_ABORT":
|
|
// Rejecting an upgrade aborts the versionchange transaction, so the
|
|
// open request fails with an `AbortError` and the caller has always
|
|
// seen ABORTED. `CALLER_ABORT` shares the arm because the journal
|
|
// exposes no cancellation — only the connection handle's own close
|
|
// cancels an open, and its waiters are told CLOSED instead.
|
|
return failureOf(browserDataFailure("ABORTED", operation));
|
|
case "ADMISSION_REJECTED":
|
|
// Unreachable: no `admit` callback is installed. The journal validates
|
|
// stored rows per transaction rather than at open time.
|
|
return failureOf(
|
|
browserDataFailure("UNAVAILABLE", operation, {
|
|
retryable: true,
|
|
recovery: "REOPEN",
|
|
}),
|
|
);
|
|
default: {
|
|
const exhaustive: never = cause;
|
|
return exhaustive;
|
|
}
|
|
}
|
|
};
|
|
}
|