1804 lines
56 KiB
TypeScript
1804 lines
56 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 { 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> = Readonly<{
|
|
succeed(value: Value): void;
|
|
fail(result: BrowserDataResult<never>): void;
|
|
}>;
|
|
|
|
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;
|
|
const scheduler =
|
|
dependencies.scheduler ??
|
|
Object.freeze({
|
|
setTimeout: (callback: () => void, milliseconds: number) =>
|
|
globalThis.setTimeout(callback, milliseconds),
|
|
clearTimeout: (handle: unknown) =>
|
|
globalThis.clearTimeout(
|
|
handle as ReturnType<typeof globalThis.setTimeout>,
|
|
),
|
|
});
|
|
|
|
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.");
|
|
}
|
|
|
|
let database: IDBDatabase | null = null;
|
|
let opening: Promise<BrowserDataResult<IDBDatabase>> | null = null;
|
|
let closed = false;
|
|
|
|
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) => {
|
|
const request = transaction
|
|
.objectStore(OBJECT_STORE)
|
|
.get(logicalObjectKey(scope, objectId));
|
|
request.onsuccess = () => {
|
|
if (request.result === undefined) {
|
|
context.succeed(null);
|
|
return;
|
|
}
|
|
if (
|
|
!isStoredObjectRow(request.result) ||
|
|
!sameScope(request.result.preparedObject.descriptor.scope, scope)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_READ"));
|
|
return;
|
|
}
|
|
context.succeed(request.result.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);
|
|
const bindingRequest = bindingStore.get(scopeKey);
|
|
bindingRequest.onsuccess = () => {
|
|
const storedBinding = bindingRequest.result;
|
|
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,
|
|
);
|
|
const logicalRequest = logicalBindingStore.get(
|
|
expectedLogicalBinding.logicalScopeKey,
|
|
);
|
|
logicalRequest.onsuccess = () => {
|
|
const storedLogicalBinding = logicalRequest.result;
|
|
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) {
|
|
bindingStore.add(expectedBinding);
|
|
logicalBindingStore.add(expectedLogicalBinding);
|
|
}
|
|
};
|
|
};
|
|
const logicalKey = logicalObjectKey(
|
|
input.scope,
|
|
input.objectId,
|
|
);
|
|
const objectRequest = nativeTransaction
|
|
.objectStore(OBJECT_STORE)
|
|
.get(logicalKey);
|
|
objectRequest.onsuccess = () => {
|
|
const storedCurrent = objectRequest.result;
|
|
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);
|
|
const budgetRequest = nativeTransaction
|
|
.objectStore(BUDGET_STORE)
|
|
.get(budgetKey);
|
|
budgetRequest.onsuccess = () => {
|
|
const budgetResult = budgetRequest.result;
|
|
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,
|
|
});
|
|
nativeTransaction
|
|
.objectStore(BUDGET_STORE)
|
|
.put(nextBudget);
|
|
const row: StoredJournalRow = Object.freeze({
|
|
...input,
|
|
logicalKey,
|
|
fencingToken,
|
|
phase: "PREPARING",
|
|
budgetReservation: Object.freeze({
|
|
namespace: input.scope.namespace,
|
|
reservedBytes,
|
|
hardBudgetBytes:
|
|
input.targetStoragePolicy.hardBudgetBytes,
|
|
}),
|
|
});
|
|
nativeTransaction
|
|
.objectStore(JOURNAL_STORE)
|
|
.add(row);
|
|
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,
|
|
});
|
|
store.put(updated);
|
|
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);
|
|
const request = store.get(transactionId);
|
|
request.onsuccess = () => {
|
|
if (request.result === undefined) {
|
|
context.succeed(undefined);
|
|
return;
|
|
}
|
|
if (
|
|
!isStoredJournalRow(request.result) ||
|
|
!isBoundScope(request.result.scope) ||
|
|
request.result.fencingToken !== fencingToken ||
|
|
request.result.phase !== "COMMITTED"
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
store.delete(transactionId);
|
|
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);
|
|
const request = journalStore.get(transactionId);
|
|
request.onsuccess = () => {
|
|
if (request.result === undefined) {
|
|
context.succeed(undefined);
|
|
return;
|
|
}
|
|
const row = request.result;
|
|
if (
|
|
!isStoredJournalRow(row) ||
|
|
!isBoundScope(row.scope) ||
|
|
row.fencingToken !== fencingToken ||
|
|
row.phase === "COMMITTED"
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const budgetStore =
|
|
transaction.objectStore(BUDGET_STORE);
|
|
const budgetRequest = budgetStore.get(
|
|
storageBudgetKey(row.scope),
|
|
);
|
|
budgetRequest.onsuccess = () => {
|
|
if (!isBudgetRow(budgetRequest.result)) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const budget = budgetRequest.result;
|
|
if (
|
|
budget.reservedBytes <
|
|
row.budgetReservation.reservedBytes
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
putOrDeleteBudget(
|
|
budgetStore,
|
|
Object.freeze({
|
|
...budget,
|
|
reservedBytes:
|
|
budget.reservedBytes -
|
|
row.budgetReservation.reservedBytes,
|
|
}),
|
|
);
|
|
journalStore.delete(transactionId);
|
|
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[] = [];
|
|
const request = transaction
|
|
.objectStore(JOURNAL_STORE)
|
|
.index(STARTED_AT_INDEX)
|
|
.openCursor();
|
|
request.onsuccess = () => {
|
|
const cursor = request.result;
|
|
if (!cursor) {
|
|
context.succeed(
|
|
Object.freeze({
|
|
transactions: Object.freeze(rows),
|
|
moreAvailable: false,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
!isStoredJournalRow(cursor.value) ||
|
|
!isBoundScope(cursor.value.scope)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_READ"));
|
|
return;
|
|
}
|
|
if (rows.length === limit) {
|
|
context.succeed(
|
|
Object.freeze({
|
|
transactions: Object.freeze(rows),
|
|
moreAvailable: true,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
rows.push(cursor.value);
|
|
cursor.continue();
|
|
};
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
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[] = [];
|
|
const cursorRequest = transaction
|
|
.objectStore(OBJECT_STORE)
|
|
.index(SCOPE_OBJECT_INDEX)
|
|
.openCursor(range);
|
|
cursorRequest.onsuccess = () => {
|
|
const cursor = cursorRequest.result;
|
|
if (!cursor) {
|
|
context.succeed(
|
|
Object.freeze({
|
|
objects: Object.freeze(objects),
|
|
nextObjectId: null,
|
|
moreAvailable: false,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
!isStoredObjectRow(cursor.value) ||
|
|
!sameScope(
|
|
cursor.value.preparedObject.descriptor.scope,
|
|
request.scope,
|
|
)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_READ"));
|
|
return;
|
|
}
|
|
if (objects.length === request.limit) {
|
|
context.succeed(
|
|
Object.freeze({
|
|
objects: Object.freeze(objects),
|
|
nextObjectId:
|
|
objects.at(-1)?.descriptor.objectId ?? null,
|
|
moreAvailable: true,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
objects.push(cursor.value.preparedObject);
|
|
cursor.continue();
|
|
};
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
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) => {
|
|
const request = transaction
|
|
.objectStore(CHUNK_REFERENCE_STORE)
|
|
.get(referenceKey);
|
|
request.onsuccess = () => {
|
|
if (request.result === undefined) {
|
|
context.succeed(false);
|
|
return;
|
|
}
|
|
if (
|
|
!isChunkReference(request.result) ||
|
|
request.result.referenceKey !== referenceKey ||
|
|
request.result.scopeKey !== storageScopeKey(scope)
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_READ"));
|
|
return;
|
|
}
|
|
context.succeed(request.result.referenceCount > 0);
|
|
};
|
|
},
|
|
),
|
|
);
|
|
},
|
|
|
|
close() {
|
|
closed = true;
|
|
database?.close();
|
|
database = null;
|
|
opening = null;
|
|
},
|
|
};
|
|
|
|
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);
|
|
const journalRequest = journalStore.get(transactionId);
|
|
journalRequest.onsuccess = () => {
|
|
const row = journalRequest.result;
|
|
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);
|
|
const objectRequest = objectStore.get(row.logicalKey);
|
|
objectRequest.onsuccess = () => {
|
|
const storedCurrent = objectRequest.result;
|
|
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);
|
|
const budgetRequest = budgetStore.get(
|
|
storageBudgetKey(row.scope),
|
|
);
|
|
budgetRequest.onsuccess = () => {
|
|
if (!isBudgetRow(budgetRequest.result)) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const budget = budgetRequest.result;
|
|
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,
|
|
}),
|
|
);
|
|
if (mutation === "PUT") {
|
|
objectStore.put(
|
|
storedObjectRow(row.preparedObject!),
|
|
);
|
|
} else {
|
|
objectStore.delete(row.logicalKey);
|
|
}
|
|
const committed: StoredJournalRow = Object.freeze({
|
|
...row,
|
|
phase: "COMMITTED",
|
|
});
|
|
journalStore.put(committed);
|
|
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);
|
|
const request = store.get(transactionId);
|
|
request.onsuccess = () => {
|
|
if (
|
|
!isStoredJournalRow(request.result) ||
|
|
!isBoundScope(request.result.scope) ||
|
|
request.result.fencingToken !== fencingToken
|
|
) {
|
|
context.fail(conflict("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
update(request.result, context, store);
|
|
};
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
async function withDatabase<Value>(
|
|
operation: BrowserDataOperation,
|
|
task: (db: IDBDatabase) => Promise<BrowserDataResult<Value>>,
|
|
): Promise<BrowserDataResult<Value>> {
|
|
const opened = await openDatabase();
|
|
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;
|
|
}
|
|
}
|
|
|
|
async function openDatabase(): Promise<BrowserDataResult<IDBDatabase>> {
|
|
if (closed || !factory) {
|
|
return browserDataFailure("UNSUPPORTED", "INDEXEDDB_OPEN", {
|
|
recovery: "ONLINE_ONLY",
|
|
});
|
|
}
|
|
if (database) return browserDataSuccess(database);
|
|
if (opening) return await opening;
|
|
|
|
const currentOpening = new Promise<BrowserDataResult<IDBDatabase>>(
|
|
(resolve) => {
|
|
let settled = false;
|
|
let blockedTimer: unknown;
|
|
const settle = (
|
|
result: BrowserDataResult<IDBDatabase>,
|
|
): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (blockedTimer !== undefined) {
|
|
scheduler.clearTimeout(blockedTimer);
|
|
}
|
|
resolve(result);
|
|
};
|
|
let request: IDBOpenDBRequest;
|
|
try {
|
|
request = factory.open(databaseName, DATABASE_VERSION);
|
|
} catch (error) {
|
|
settle(mapIndexedDbException(error, "INDEXEDDB_OPEN"));
|
|
return;
|
|
}
|
|
request.onupgradeneeded = (event) => {
|
|
const db = request.result;
|
|
if (event.oldVersion !== 0) {
|
|
request.transaction?.abort();
|
|
return;
|
|
}
|
|
const journalStore = db.createObjectStore(JOURNAL_STORE, {
|
|
keyPath: "transactionId",
|
|
});
|
|
journalStore.createIndex(
|
|
LOGICAL_KEY_INDEX,
|
|
"logicalKey",
|
|
{ unique: true },
|
|
);
|
|
journalStore.createIndex(
|
|
STARTED_AT_INDEX,
|
|
"startedAtEpochMs",
|
|
{ unique: false },
|
|
);
|
|
const objectStore = db.createObjectStore(OBJECT_STORE, {
|
|
keyPath: "logicalKey",
|
|
});
|
|
objectStore.createIndex(
|
|
SCOPE_OBJECT_INDEX,
|
|
"scopeObjectKey",
|
|
{ unique: true },
|
|
);
|
|
db.createObjectStore(BUDGET_STORE, {
|
|
keyPath: "budgetKey",
|
|
});
|
|
db.createObjectStore(SCOPE_BINDING_STORE, {
|
|
keyPath: "scopeKey",
|
|
});
|
|
db.createObjectStore(LOGICAL_SCOPE_BINDING_STORE, {
|
|
keyPath: "logicalScopeKey",
|
|
});
|
|
db.createObjectStore(CHUNK_REFERENCE_STORE, {
|
|
keyPath: "referenceKey",
|
|
});
|
|
};
|
|
request.onblocked = () => {
|
|
if (blockedTimer !== undefined) return;
|
|
blockedTimer = scheduler.setTimeout(() => {
|
|
settle(
|
|
browserDataFailure("BLOCKED", "INDEXEDDB_OPEN", {
|
|
retryable: true,
|
|
recovery: "RELOAD_OTHER_CONTEXTS",
|
|
}),
|
|
);
|
|
}, blockedTimeoutMs);
|
|
};
|
|
request.onerror = () =>
|
|
settle(
|
|
mapIndexedDbException(
|
|
request.error,
|
|
"INDEXEDDB_OPEN",
|
|
),
|
|
);
|
|
request.onsuccess = () => {
|
|
if (settled || closed) {
|
|
request.result.close();
|
|
return;
|
|
}
|
|
database = request.result;
|
|
database.onversionchange = () => {
|
|
database?.close();
|
|
database = null;
|
|
opening = null;
|
|
};
|
|
database.onclose = () => {
|
|
database = null;
|
|
opening = null;
|
|
};
|
|
settle(browserDataSuccess(request.result));
|
|
};
|
|
},
|
|
).finally(() => {
|
|
if (opening === currentOpening) opening = null;
|
|
});
|
|
opening = currentOpening;
|
|
return await currentOpening;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function runTransaction<Value>(
|
|
database: IDBDatabase,
|
|
storeNames: readonly string[],
|
|
mode: IDBTransactionMode,
|
|
operation: BrowserDataOperation,
|
|
run: (
|
|
transaction: IDBTransaction,
|
|
context: TransactionContext<Value>,
|
|
) => void,
|
|
): Promise<BrowserDataResult<Value>> {
|
|
return new Promise((resolve) => {
|
|
let value: Value | undefined;
|
|
let hasValue = false;
|
|
let explicitFailure: BrowserDataResult<never> | null = null;
|
|
let settled = false;
|
|
let transaction: IDBTransaction;
|
|
try {
|
|
transaction =
|
|
mode === "readwrite"
|
|
? strictReadwriteTransaction(database, storeNames)
|
|
: database.transaction([...storeNames], mode);
|
|
} catch (error) {
|
|
resolve(mapIndexedDbException(error, operation));
|
|
return;
|
|
}
|
|
const settle = (result: BrowserDataResult<Value>): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(result);
|
|
};
|
|
const context: TransactionContext<Value> = {
|
|
succeed(nextValue) {
|
|
value = nextValue;
|
|
hasValue = true;
|
|
},
|
|
fail(result) {
|
|
if (explicitFailure) return;
|
|
explicitFailure = result;
|
|
try {
|
|
transaction.abort();
|
|
} catch {
|
|
settle(result);
|
|
}
|
|
},
|
|
};
|
|
transaction.oncomplete = () => {
|
|
if (!hasValue) {
|
|
settle(
|
|
browserDataFailure("UNAVAILABLE", operation, {
|
|
retryable: true,
|
|
recovery: "REOPEN",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
settle(browserDataSuccess(value as Value));
|
|
};
|
|
transaction.onabort = () =>
|
|
settle(
|
|
explicitFailure ??
|
|
mapIndexedDbException(transaction.error, operation),
|
|
);
|
|
transaction.onerror = () => {
|
|
// onabort owns the single failure result.
|
|
};
|
|
try {
|
|
run(transaction, context);
|
|
} catch (error) {
|
|
explicitFailure = mapIndexedDbException(error, operation);
|
|
try {
|
|
transaction.abort();
|
|
} catch {
|
|
settle(explicitFailure);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function strictReadwriteTransaction(
|
|
database: IDBDatabase,
|
|
storeNames: readonly string[],
|
|
): IDBTransaction {
|
|
try {
|
|
return database.transaction([...storeNames], "readwrite", {
|
|
durability: "strict",
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof TypeError) {
|
|
return database.transaction([...storeNames], "readwrite");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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);
|
|
const request = store.get(referenceKey);
|
|
request.onsuccess = () => {
|
|
if (
|
|
request.result !== undefined &&
|
|
(!isChunkReference(request.result) ||
|
|
request.result.referenceKey !== referenceKey ||
|
|
request.result.scopeKey !== storageScopeKey(scope))
|
|
) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
const current =
|
|
(request.result as StoredChunkReference | undefined)
|
|
?.referenceCount ?? 0;
|
|
const next = current + delta;
|
|
if (!Number.isSafeInteger(next) || next < 0) {
|
|
context.fail(corrupt("INDEXEDDB_WRITE"));
|
|
return;
|
|
}
|
|
if (next === 0) {
|
|
store.delete(referenceKey);
|
|
} else {
|
|
store.put(
|
|
Object.freeze({
|
|
referenceKey,
|
|
scopeKey: storageScopeKey(scope),
|
|
digestHex,
|
|
referenceCount: next,
|
|
}),
|
|
);
|
|
}
|
|
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,
|
|
): void {
|
|
if (budget.committedBytes === 0 && budget.reservedBytes === 0) {
|
|
store.delete(budget.budgetKey);
|
|
} else {
|
|
store.put(budget);
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
function isPreparedObject(value: unknown): value is OpfsPreparedObject {
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
!("physicalSchemaVersion" in value) ||
|
|
value.physicalSchemaVersion !== 1 ||
|
|
!("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.");
|
|
}
|
|
|
|
function conflict(
|
|
operation: BrowserDataOperation,
|
|
): BrowserDataResult<never> {
|
|
return browserDataFailure("CONFLICT", operation, {
|
|
recovery: "REOPEN",
|
|
});
|
|
}
|
|
|
|
function corrupt(
|
|
operation: BrowserDataOperation,
|
|
): BrowserDataResult<never> {
|
|
return browserDataFailure("CORRUPT_DATA", operation, {
|
|
recovery: "READ_ONLY",
|
|
});
|
|
}
|
|
|
|
function policyRejected(
|
|
operation: BrowserDataOperation,
|
|
): BrowserDataResult<never> {
|
|
return browserDataFailure("POLICY_REJECTED", operation, {
|
|
recovery: "READ_ONLY",
|
|
});
|
|
}
|
|
|
|
function limitExceeded(
|
|
operation: BrowserDataOperation,
|
|
): BrowserDataResult<never> {
|
|
return browserDataFailure("LIMIT_EXCEEDED", operation, {
|
|
recovery: "EXPORT_REQUIRED",
|
|
});
|
|
}
|