923 lines
25 KiB
TypeScript
923 lines
25 KiB
TypeScript
type MutableRequest<Value> = {
|
|
result: Value;
|
|
error: DOMException | null;
|
|
onsuccess: ((event: Event) => unknown) | null;
|
|
onerror: ((event: Event) => unknown) | null;
|
|
};
|
|
|
|
type MutableOpenRequest = MutableRequest<IDBDatabase> & {
|
|
transaction: IDBTransaction | null;
|
|
onblocked: ((event: IDBVersionChangeEvent) => unknown) | null;
|
|
onupgradeneeded: ((event: IDBVersionChangeEvent) => unknown) | null;
|
|
};
|
|
|
|
type IndexState = {
|
|
keyPath: string | string[];
|
|
unique: boolean;
|
|
multiEntry: boolean;
|
|
};
|
|
|
|
type StoreState = {
|
|
keyPath: string;
|
|
autoIncrement: boolean;
|
|
data: Map<string, unknown>;
|
|
indexes: Map<string, IndexState>;
|
|
};
|
|
|
|
type FakeKeyRange =
|
|
| Readonly<{
|
|
fakeKind: "ONLY";
|
|
boundary: IDBValidKey;
|
|
}>
|
|
| Readonly<{
|
|
fakeKind: "LOWER";
|
|
boundary: IDBValidKey;
|
|
open: boolean;
|
|
}>
|
|
| Readonly<{
|
|
fakeKind: "UPPER";
|
|
boundary: IDBValidKey;
|
|
open: boolean;
|
|
}>
|
|
| Readonly<{
|
|
fakeKind: "BOUND";
|
|
lower: IDBValidKey;
|
|
upper: IDBValidKey;
|
|
lowerOpen: boolean;
|
|
upperOpen: boolean;
|
|
}>;
|
|
|
|
function createRequest<Value>(): MutableRequest<Value> {
|
|
return {
|
|
result: undefined as Value,
|
|
error: null,
|
|
onsuccess: null,
|
|
onerror: null,
|
|
};
|
|
}
|
|
|
|
function cloneStore(state: StoreState): StoreState {
|
|
return {
|
|
keyPath: state.keyPath,
|
|
autoIncrement: state.autoIncrement,
|
|
data: new Map(
|
|
Array.from(state.data, ([key, value]) => [
|
|
key,
|
|
structuredClone(value),
|
|
]),
|
|
),
|
|
indexes: new Map(
|
|
Array.from(state.indexes, ([name, index]) => [
|
|
name,
|
|
{
|
|
...index,
|
|
keyPath: Array.isArray(index.keyPath)
|
|
? [...index.keyPath]
|
|
: index.keyPath,
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
|
|
function cloneStores(
|
|
stores: ReadonlyMap<string, StoreState>,
|
|
): Map<string, StoreState> {
|
|
return new Map(
|
|
Array.from(stores, ([name, state]) => [name, cloneStore(state)]),
|
|
);
|
|
}
|
|
|
|
function asException(error: unknown): DOMException {
|
|
return error instanceof DOMException
|
|
? error
|
|
: new DOMException("Fake IndexedDB failure.", "UnknownError");
|
|
}
|
|
|
|
function stringList(values: Iterable<string>): DOMStringList {
|
|
const items = [...values];
|
|
return {
|
|
contains: (value: string) => items.includes(value),
|
|
item: (index: number) => items[index] ?? null,
|
|
get length() {
|
|
return items.length;
|
|
},
|
|
[Symbol.iterator]: () => items[Symbol.iterator](),
|
|
} as DOMStringList;
|
|
}
|
|
|
|
function readPath(value: unknown, keyPath: string): unknown {
|
|
let current = value;
|
|
for (const segment of keyPath.split(".")) {
|
|
if (!current || typeof current !== "object") return undefined;
|
|
current = (current as Record<string, unknown>)[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function primaryKey(state: StoreState, value: unknown): string {
|
|
const key = readPath(value, state.keyPath);
|
|
if (
|
|
(typeof key !== "string" && typeof key !== "number") ||
|
|
String(key).length === 0
|
|
) {
|
|
throw new DOMException("Invalid fake key.", "DataError");
|
|
}
|
|
return String(key);
|
|
}
|
|
|
|
function compareKeys(first: IDBValidKey, second: IDBValidKey): number {
|
|
const left =
|
|
first instanceof Date
|
|
? first.getTime()
|
|
: typeof first === "number" || typeof first === "string"
|
|
? first
|
|
: JSON.stringify(first);
|
|
const right =
|
|
second instanceof Date
|
|
? second.getTime()
|
|
: typeof second === "number" || typeof second === "string"
|
|
? second
|
|
: JSON.stringify(second);
|
|
if (left === right) return 0;
|
|
if (typeof left === "number" && typeof right === "number") {
|
|
return left < right ? -1 : 1;
|
|
}
|
|
return String(left) < String(right) ? -1 : 1;
|
|
}
|
|
|
|
class FakeUpgradeTransaction {
|
|
aborted = false;
|
|
|
|
constructor(private readonly database: FakeDatabase) {}
|
|
|
|
abort(): void {
|
|
this.aborted = true;
|
|
}
|
|
|
|
objectStore(name: string): IDBObjectStore {
|
|
return this.database.upgradeObjectStore(name, this);
|
|
}
|
|
|
|
request<Value>(operation: () => Value): IDBRequest<Value> {
|
|
const request = createRequest<Value>();
|
|
try {
|
|
request.result = structuredClone(operation());
|
|
queueMicrotask(() => request.onsuccess?.(new Event("success")));
|
|
} catch (error) {
|
|
request.error = asException(error);
|
|
this.aborted = true;
|
|
queueMicrotask(() => request.onerror?.(new Event("error")));
|
|
}
|
|
return request as unknown as IDBRequest<Value>;
|
|
}
|
|
}
|
|
|
|
class FakeDatabase {
|
|
version = 0;
|
|
closed = false;
|
|
onversionchange: ((event: IDBVersionChangeEvent) => unknown) | null = null;
|
|
onclose: ((event: Event) => unknown) | null = null;
|
|
|
|
constructor(private readonly owner: MemoryIndexedDbFactory) {}
|
|
|
|
get objectStoreNames(): DOMStringList {
|
|
return stringList(this.owner.storeNames());
|
|
}
|
|
|
|
createObjectStore(
|
|
name: string,
|
|
options?: IDBObjectStoreParameters,
|
|
): IDBObjectStore {
|
|
if (this.owner.hasStore(name)) {
|
|
throw new DOMException("Store already exists.", "ConstraintError");
|
|
}
|
|
if (typeof options?.keyPath !== "string") {
|
|
throw new DOMException("The fake requires a string key path.", "DataError");
|
|
}
|
|
this.owner.createStore(name, {
|
|
keyPath: options.keyPath,
|
|
autoIncrement: options.autoIncrement ?? false,
|
|
data: new Map(),
|
|
indexes: new Map(),
|
|
});
|
|
return this.schemaObjectStore(name);
|
|
}
|
|
|
|
deleteObjectStore(name: string): void {
|
|
if (!this.owner.deleteStore(name)) {
|
|
throw new DOMException("Store does not exist.", "NotFoundError");
|
|
}
|
|
}
|
|
|
|
schemaObjectStore(name: string): IDBObjectStore {
|
|
const state = this.owner.store(name);
|
|
return new FakeObjectStore(null, state) as unknown as IDBObjectStore;
|
|
}
|
|
|
|
upgradeObjectStore(
|
|
name: string,
|
|
transaction: FakeUpgradeTransaction,
|
|
): IDBObjectStore {
|
|
const state = this.owner.store(name);
|
|
return new FakeObjectStore(
|
|
null,
|
|
state,
|
|
transaction,
|
|
) as unknown as IDBObjectStore;
|
|
}
|
|
|
|
transaction(
|
|
storeNames: string | Iterable<string>,
|
|
mode: IDBTransactionMode = "readonly",
|
|
): IDBTransaction {
|
|
if (this.closed) {
|
|
throw new DOMException("Connection is closed.", "InvalidStateError");
|
|
}
|
|
const names =
|
|
typeof storeNames === "string" ? [storeNames] : [...storeNames];
|
|
const transaction = new FakeTransaction(this.owner, names, mode);
|
|
this.owner.noteTransaction(transaction);
|
|
return transaction as unknown as IDBTransaction;
|
|
}
|
|
|
|
close(): void {
|
|
this.closed = true;
|
|
}
|
|
|
|
fireVersionChange(newVersion: number): void {
|
|
this.onversionchange?.({
|
|
oldVersion: this.version,
|
|
newVersion,
|
|
} as IDBVersionChangeEvent);
|
|
}
|
|
|
|
fireForcedClose(): void {
|
|
this.closed = true;
|
|
this.onclose?.(new Event("close"));
|
|
}
|
|
}
|
|
|
|
class FakeTransaction {
|
|
oncomplete: ((event: Event) => unknown) | null = null;
|
|
onerror: ((event: Event) => unknown) | null = null;
|
|
onabort: ((event: Event) => unknown) | null = null;
|
|
error: DOMException | null = null;
|
|
readonly mode: IDBTransactionMode;
|
|
private active = true;
|
|
private completionQueued = false;
|
|
private pending = 0;
|
|
private readonly workingStores: Map<string, StoreState>;
|
|
|
|
constructor(
|
|
private readonly owner: MemoryIndexedDbFactory,
|
|
private readonly names: readonly string[],
|
|
mode: IDBTransactionMode,
|
|
) {
|
|
this.mode = mode;
|
|
this.workingStores = new Map(
|
|
names.map((name) => [name, cloneStore(owner.store(name))]),
|
|
);
|
|
}
|
|
|
|
objectStore(name: string): IDBObjectStore {
|
|
if (!this.names.includes(name)) {
|
|
throw new DOMException("Store is outside transaction.", "NotFoundError");
|
|
}
|
|
const state = this.workingStores.get(name);
|
|
if (!state) {
|
|
throw new DOMException("Store does not exist.", "NotFoundError");
|
|
}
|
|
return new FakeObjectStore(this, state) as unknown as IDBObjectStore;
|
|
}
|
|
|
|
abort(): void {
|
|
if (!this.active) {
|
|
throw new DOMException("Transaction is inactive.", "InvalidStateError");
|
|
}
|
|
this.abortWith(new DOMException("Transaction aborted.", "AbortError"));
|
|
}
|
|
|
|
request<Value>(operation: () => Value): IDBRequest<Value> {
|
|
const request = createRequest<Value>();
|
|
this.pending += 1;
|
|
this.owner.scheduleTransaction(() => {
|
|
if (!this.active) return;
|
|
try {
|
|
request.result = structuredClone(operation());
|
|
request.onsuccess?.(new Event("success"));
|
|
} catch (error) {
|
|
request.error = asException(error);
|
|
request.onerror?.(new Event("error"));
|
|
this.abortWith(request.error);
|
|
}
|
|
if (!this.active) return;
|
|
this.pending -= 1;
|
|
this.queueCompletion();
|
|
}, this.mode);
|
|
return request as unknown as IDBRequest<Value>;
|
|
}
|
|
|
|
cursor(
|
|
rows: readonly Readonly<{
|
|
key: IDBValidKey;
|
|
primaryKey: IDBValidKey;
|
|
value: unknown;
|
|
}>[],
|
|
direction: IDBCursorDirection,
|
|
): IDBRequest<IDBCursorWithValue | null> {
|
|
const request = createRequest<IDBCursorWithValue | null>();
|
|
let position = 0;
|
|
this.pending += 1;
|
|
|
|
const emit = () => {
|
|
this.owner.scheduleTransaction(() => {
|
|
if (!this.active) return;
|
|
let continued = false;
|
|
let eventReturned = false;
|
|
const row = rows[position];
|
|
if (!row) {
|
|
request.result = null;
|
|
} else {
|
|
request.result = {
|
|
key: structuredClone(row.key),
|
|
primaryKey: structuredClone(row.primaryKey),
|
|
value: structuredClone(row.value),
|
|
continue: (key?: IDBValidKey) => {
|
|
if (continued) {
|
|
throw new DOMException(
|
|
"Cursor already continued.",
|
|
"InvalidStateError",
|
|
);
|
|
}
|
|
continued = true;
|
|
if (eventReturned) this.pending += 1;
|
|
if (key === undefined) {
|
|
position += 1;
|
|
} else {
|
|
const nextPosition = rows.findIndex(
|
|
(candidate, candidateIndex) =>
|
|
candidateIndex > position &&
|
|
(direction === "prev" ||
|
|
direction === "prevunique"
|
|
? compareKeys(candidate.key, key) <= 0
|
|
: compareKeys(candidate.key, key) >= 0),
|
|
);
|
|
position =
|
|
nextPosition < 0 ? rows.length : nextPosition;
|
|
}
|
|
emit();
|
|
},
|
|
continuePrimaryKey: (
|
|
key: IDBValidKey,
|
|
primaryKey: IDBValidKey,
|
|
) => {
|
|
if (continued) {
|
|
throw new DOMException(
|
|
"Cursor already continued.",
|
|
"InvalidStateError",
|
|
);
|
|
}
|
|
continued = true;
|
|
if (eventReturned) this.pending += 1;
|
|
const nextPosition = rows.findIndex(
|
|
(candidate, candidateIndex) => {
|
|
if (candidateIndex <= position) return false;
|
|
const keyOrder = compareKeys(candidate.key, key);
|
|
const tupleOrder =
|
|
keyOrder === 0
|
|
? compareKeys(
|
|
candidate.primaryKey,
|
|
primaryKey,
|
|
)
|
|
: keyOrder;
|
|
return direction === "prev" ||
|
|
direction === "prevunique"
|
|
? tupleOrder <= 0
|
|
: tupleOrder >= 0;
|
|
},
|
|
);
|
|
position =
|
|
nextPosition < 0 ? rows.length : nextPosition;
|
|
emit();
|
|
},
|
|
} as unknown as IDBCursorWithValue;
|
|
}
|
|
request.onsuccess?.(new Event("success"));
|
|
eventReturned = true;
|
|
if (continued || !this.active) return;
|
|
this.pending -= 1;
|
|
this.queueCompletion();
|
|
}, this.mode);
|
|
};
|
|
|
|
emit();
|
|
return request as unknown as IDBRequest<IDBCursorWithValue | null>;
|
|
}
|
|
|
|
assertWritable(): void {
|
|
if (this.mode !== "readwrite") {
|
|
throw new DOMException("Transaction is read-only.", "ReadOnlyError");
|
|
}
|
|
}
|
|
|
|
private queueCompletion(): void {
|
|
if (
|
|
!this.active ||
|
|
this.pending !== 0 ||
|
|
this.completionQueued
|
|
) {
|
|
return;
|
|
}
|
|
this.completionQueued = true;
|
|
this.owner.scheduleTransaction(() => {
|
|
this.completionQueued = false;
|
|
if (!this.active || this.pending !== 0) return;
|
|
const commitFailure =
|
|
this.mode === "readwrite"
|
|
? this.owner.consumeCommitFailure()
|
|
: null;
|
|
if (commitFailure) {
|
|
this.abortWith(commitFailure);
|
|
return;
|
|
}
|
|
if (this.mode === "readwrite") {
|
|
for (const [name, state] of this.workingStores) {
|
|
this.owner.replaceStore(name, cloneStore(state));
|
|
}
|
|
}
|
|
this.active = false;
|
|
this.oncomplete?.(new Event("complete"));
|
|
}, this.mode);
|
|
}
|
|
|
|
private abortWith(error: DOMException): void {
|
|
if (!this.active) return;
|
|
this.active = false;
|
|
this.error = error;
|
|
this.owner.scheduleLifecycle(() => {
|
|
this.onerror?.(new Event("error"));
|
|
this.onabort?.(new Event("abort"));
|
|
});
|
|
}
|
|
}
|
|
|
|
class FakeObjectStore {
|
|
constructor(
|
|
private readonly transaction: FakeTransaction | null,
|
|
private readonly state: StoreState,
|
|
private readonly upgradeTransaction: FakeUpgradeTransaction | null = null,
|
|
) {}
|
|
|
|
get indexNames(): DOMStringList {
|
|
return stringList(this.state.indexes.keys());
|
|
}
|
|
|
|
get(query: IDBValidKey | IDBKeyRange): IDBRequest<unknown> {
|
|
return this.request(() =>
|
|
this.state.data.get(String(query)),
|
|
);
|
|
}
|
|
|
|
put(value: unknown): IDBRequest<IDBValidKey> {
|
|
this.assertWritable();
|
|
return this.request(() => {
|
|
const key = primaryKey(this.state, value);
|
|
this.state.data.set(key, structuredClone(value));
|
|
return key as IDBValidKey;
|
|
});
|
|
}
|
|
|
|
add(value: unknown): IDBRequest<IDBValidKey> {
|
|
this.assertWritable();
|
|
return this.request(() => {
|
|
const key = primaryKey(this.state, value);
|
|
if (this.state.data.has(key)) {
|
|
throw new DOMException("Key already exists.", "ConstraintError");
|
|
}
|
|
this.state.data.set(key, structuredClone(value));
|
|
return key as IDBValidKey;
|
|
});
|
|
}
|
|
|
|
delete(query: IDBValidKey | IDBKeyRange): IDBRequest<undefined> {
|
|
this.assertWritable();
|
|
return this.request(() => {
|
|
this.state.data.delete(String(query));
|
|
return undefined;
|
|
});
|
|
}
|
|
|
|
openCursor(
|
|
query?: IDBValidKey | IDBKeyRange | null,
|
|
direction: IDBCursorDirection = "next",
|
|
): IDBRequest<IDBCursorWithValue | null> {
|
|
return this.requireTransaction().cursor(
|
|
this.rows(undefined, direction, query),
|
|
direction,
|
|
);
|
|
}
|
|
|
|
index(name: string): IDBIndex {
|
|
if (!this.state.indexes.has(name)) {
|
|
throw new DOMException("Index does not exist.", "NotFoundError");
|
|
}
|
|
return new FakeIndex(
|
|
this.requireTransaction(),
|
|
this.state,
|
|
name,
|
|
) as unknown as IDBIndex;
|
|
}
|
|
|
|
createIndex(
|
|
name: string,
|
|
keyPath: string | string[],
|
|
options?: IDBIndexParameters,
|
|
): IDBIndex {
|
|
if (this.state.indexes.has(name)) {
|
|
throw new DOMException("Index already exists.", "ConstraintError");
|
|
}
|
|
this.state.indexes.set(name, {
|
|
keyPath: Array.isArray(keyPath) ? [...keyPath] : keyPath,
|
|
unique: options?.unique ?? false,
|
|
multiEntry: options?.multiEntry ?? false,
|
|
});
|
|
return {} as IDBIndex;
|
|
}
|
|
|
|
deleteIndex(name: string): void {
|
|
if (!this.state.indexes.delete(name)) {
|
|
throw new DOMException("Index does not exist.", "NotFoundError");
|
|
}
|
|
}
|
|
|
|
rows(
|
|
indexName: string | undefined,
|
|
direction: IDBCursorDirection,
|
|
query?: IDBValidKey | IDBKeyRange | null,
|
|
): readonly Readonly<{
|
|
key: IDBValidKey;
|
|
primaryKey: IDBValidKey;
|
|
value: unknown;
|
|
}>[] {
|
|
const index = indexName
|
|
? this.state.indexes.get(indexName)
|
|
: undefined;
|
|
let rows = Array.from(this.state.data, ([key, value]) => {
|
|
const indexKey =
|
|
index && typeof index.keyPath === "string"
|
|
? readPath(value, index.keyPath)
|
|
: key;
|
|
if (index && indexKey === undefined) return null;
|
|
return {
|
|
key: indexKey as IDBValidKey,
|
|
primaryKey: key,
|
|
value,
|
|
};
|
|
}).filter(
|
|
(
|
|
row,
|
|
): row is {
|
|
key: IDBValidKey;
|
|
primaryKey: string;
|
|
value: unknown;
|
|
} => row !== null,
|
|
);
|
|
if (
|
|
query &&
|
|
typeof query === "object" &&
|
|
"fakeKind" in query
|
|
) {
|
|
const range = query as unknown as FakeKeyRange;
|
|
rows = rows.filter((row) => {
|
|
if (range.fakeKind === "BOUND") {
|
|
const lower = compareKeys(row.key, range.lower);
|
|
const upper = compareKeys(row.key, range.upper);
|
|
return (
|
|
(range.lowerOpen ? lower > 0 : lower >= 0) &&
|
|
(range.upperOpen ? upper < 0 : upper <= 0)
|
|
);
|
|
}
|
|
const comparison = compareKeys(row.key, range.boundary);
|
|
if (range.fakeKind === "ONLY") return comparison === 0;
|
|
if (range.fakeKind === "LOWER") {
|
|
return range.open
|
|
? comparison > 0
|
|
: comparison >= 0;
|
|
}
|
|
return range.open
|
|
? comparison < 0
|
|
: comparison <= 0;
|
|
});
|
|
}
|
|
rows.sort((left, right) => {
|
|
const indexOrder = compareKeys(left.key, right.key);
|
|
return indexOrder === 0
|
|
? compareKeys(left.primaryKey, right.primaryKey)
|
|
: indexOrder;
|
|
});
|
|
if (direction === "prev" || direction === "prevunique") {
|
|
rows.reverse();
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
private requireTransaction(): FakeTransaction {
|
|
if (!this.transaction) {
|
|
throw new DOMException(
|
|
"Schema object store has no data transaction.",
|
|
"TransactionInactiveError",
|
|
);
|
|
}
|
|
return this.transaction;
|
|
}
|
|
|
|
private request<Value>(operation: () => Value): IDBRequest<Value> {
|
|
if (this.transaction) return this.transaction.request(operation);
|
|
if (this.upgradeTransaction) {
|
|
return this.upgradeTransaction.request(operation);
|
|
}
|
|
throw new DOMException(
|
|
"Schema object store has no data transaction.",
|
|
"TransactionInactiveError",
|
|
);
|
|
}
|
|
|
|
private assertWritable(): void {
|
|
if (this.transaction) {
|
|
this.transaction.assertWritable();
|
|
return;
|
|
}
|
|
if (!this.upgradeTransaction) {
|
|
throw new DOMException(
|
|
"Schema object store has no data transaction.",
|
|
"TransactionInactiveError",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeIndex {
|
|
constructor(
|
|
private readonly transaction: FakeTransaction,
|
|
private readonly state: StoreState,
|
|
private readonly name: string,
|
|
) {}
|
|
|
|
openCursor(
|
|
query?: IDBValidKey | IDBKeyRange | null,
|
|
direction: IDBCursorDirection = "next",
|
|
): IDBRequest<IDBCursorWithValue | null> {
|
|
const store = new FakeObjectStore(this.transaction, this.state);
|
|
return this.transaction.cursor(
|
|
store.rows(this.name, direction, query),
|
|
direction,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Minimal deterministic IndexedDB model for adapter contract tests. It models
|
|
* request ordering, transaction-level commit/rollback and upgrade blocking;
|
|
* it is not intended to polyfill IndexedDB for application code.
|
|
*/
|
|
export class MemoryIndexedDbFactory {
|
|
private stores = new Map<string, StoreState>();
|
|
private databaseVersion = 0;
|
|
private latestDatabase = new FakeDatabase(this);
|
|
private blockedRequest: Readonly<{
|
|
request: MutableOpenRequest;
|
|
targetVersion: number;
|
|
database: FakeDatabase;
|
|
}> | null = null;
|
|
private transactionQueue: Array<() => void> = [];
|
|
private transactionsPaused = false;
|
|
private nextCommitFailure: DOMException | null = null;
|
|
private shouldBlockNextOpen = false;
|
|
lastTransaction: FakeTransaction | null = null;
|
|
|
|
readonly factory: IDBFactory = {
|
|
open: (_name: string, version?: number) =>
|
|
this.open(version ?? 1) as unknown as IDBOpenDBRequest,
|
|
cmp: compareKeys,
|
|
} as IDBFactory;
|
|
|
|
readonly keyRange: Pick<
|
|
typeof IDBKeyRange,
|
|
"only" | "lowerBound" | "upperBound" | "bound"
|
|
> = {
|
|
only: (value: IDBValidKey) =>
|
|
({
|
|
fakeKind: "ONLY",
|
|
boundary: structuredClone(value),
|
|
}) as unknown as IDBKeyRange,
|
|
lowerBound: (bound: IDBValidKey, open = false) =>
|
|
({
|
|
fakeKind: "LOWER",
|
|
boundary: structuredClone(bound),
|
|
open,
|
|
}) as unknown as IDBKeyRange,
|
|
upperBound: (bound: IDBValidKey, open = false) =>
|
|
({
|
|
fakeKind: "UPPER",
|
|
boundary: structuredClone(bound),
|
|
open,
|
|
}) as unknown as IDBKeyRange,
|
|
bound: (
|
|
lower: IDBValidKey,
|
|
upper: IDBValidKey,
|
|
lowerOpen = false,
|
|
upperOpen = false,
|
|
) =>
|
|
({
|
|
fakeKind: "BOUND",
|
|
lower: structuredClone(lower),
|
|
upper: structuredClone(upper),
|
|
lowerOpen,
|
|
upperOpen,
|
|
}) as unknown as IDBKeyRange,
|
|
};
|
|
|
|
open(version: number): MutableOpenRequest {
|
|
const database = new FakeDatabase(this);
|
|
database.version = this.databaseVersion;
|
|
this.latestDatabase = database;
|
|
const request: MutableOpenRequest = {
|
|
...createRequest<IDBDatabase>(),
|
|
result: database as unknown as IDBDatabase,
|
|
transaction: null,
|
|
onblocked: null,
|
|
onupgradeneeded: null,
|
|
};
|
|
queueMicrotask(() => {
|
|
if (this.shouldBlockNextOpen) {
|
|
this.shouldBlockNextOpen = false;
|
|
this.blockedRequest = {
|
|
request,
|
|
targetVersion: version,
|
|
database,
|
|
};
|
|
request.onblocked?.({
|
|
oldVersion: this.databaseVersion,
|
|
newVersion: version,
|
|
} as IDBVersionChangeEvent);
|
|
return;
|
|
}
|
|
this.finishOpen(request, version, database);
|
|
});
|
|
return request;
|
|
}
|
|
|
|
blockNextOpen(): void {
|
|
this.shouldBlockNextOpen = true;
|
|
}
|
|
|
|
releaseBlockedOpen(): void {
|
|
const blocked = this.blockedRequest;
|
|
this.blockedRequest = null;
|
|
if (blocked) {
|
|
this.finishOpen(
|
|
blocked.request,
|
|
blocked.targetVersion,
|
|
blocked.database,
|
|
);
|
|
}
|
|
}
|
|
|
|
pauseTransactions(): void {
|
|
this.transactionsPaused = true;
|
|
}
|
|
|
|
clearLastTransaction(): void {
|
|
this.lastTransaction = null;
|
|
}
|
|
|
|
resumeTransactions(): void {
|
|
this.transactionsPaused = false;
|
|
const queued = this.transactionQueue;
|
|
this.transactionQueue = [];
|
|
for (const callback of queued) queueMicrotask(callback);
|
|
}
|
|
|
|
failNextWriteCommit(error: DOMException): void {
|
|
this.nextCommitFailure = error;
|
|
}
|
|
|
|
triggerVersionChange(newVersion: number): void {
|
|
this.latestDatabase.fireVersionChange(newVersion);
|
|
}
|
|
|
|
triggerForcedClose(): void {
|
|
this.latestDatabase.fireForcedClose();
|
|
}
|
|
|
|
isConnectionClosed(): boolean {
|
|
return this.latestDatabase.closed;
|
|
}
|
|
|
|
seed(storeName: string, value: unknown): void {
|
|
const state = this.store(storeName);
|
|
const key = primaryKey(state, value);
|
|
state.data.set(key, structuredClone(value));
|
|
}
|
|
|
|
readRaw(storeName: string, key: string): unknown {
|
|
const value = this.store(storeName).data.get(key);
|
|
return value === undefined ? undefined : structuredClone(value);
|
|
}
|
|
|
|
scheduleTransaction(
|
|
callback: () => void,
|
|
mode?: IDBTransactionMode,
|
|
): void {
|
|
if (this.transactionsPaused && mode === "readwrite") {
|
|
this.transactionQueue.push(callback);
|
|
return;
|
|
}
|
|
queueMicrotask(callback);
|
|
}
|
|
|
|
scheduleLifecycle(callback: () => void): void {
|
|
queueMicrotask(callback);
|
|
}
|
|
|
|
consumeCommitFailure(): DOMException | null {
|
|
const failure = this.nextCommitFailure;
|
|
this.nextCommitFailure = null;
|
|
return failure;
|
|
}
|
|
|
|
noteTransaction(transaction: FakeTransaction): void {
|
|
this.lastTransaction = transaction;
|
|
}
|
|
|
|
storeNames(): Iterable<string> {
|
|
return this.stores.keys();
|
|
}
|
|
|
|
hasStore(name: string): boolean {
|
|
return this.stores.has(name);
|
|
}
|
|
|
|
store(name: string): StoreState {
|
|
const state = this.stores.get(name);
|
|
if (!state) {
|
|
throw new DOMException("Store does not exist.", "NotFoundError");
|
|
}
|
|
return state;
|
|
}
|
|
|
|
createStore(name: string, state: StoreState): void {
|
|
this.stores.set(name, state);
|
|
}
|
|
|
|
replaceStore(name: string, state: StoreState): void {
|
|
this.stores.set(name, state);
|
|
}
|
|
|
|
deleteStore(name: string): boolean {
|
|
return this.stores.delete(name);
|
|
}
|
|
|
|
private finishOpen(
|
|
request: MutableOpenRequest,
|
|
targetVersion: number,
|
|
database: FakeDatabase,
|
|
): void {
|
|
if (targetVersion < this.databaseVersion) {
|
|
request.error = new DOMException(
|
|
"Requested version is older.",
|
|
"VersionError",
|
|
);
|
|
request.onerror?.(new Event("error"));
|
|
return;
|
|
}
|
|
if (targetVersion > this.databaseVersion) {
|
|
const before = cloneStores(this.stores);
|
|
const oldVersion = this.databaseVersion;
|
|
const upgrade = new FakeUpgradeTransaction(database);
|
|
request.transaction = upgrade as unknown as IDBTransaction;
|
|
database.version = targetVersion;
|
|
request.onupgradeneeded?.({
|
|
oldVersion,
|
|
newVersion: targetVersion,
|
|
} as IDBVersionChangeEvent);
|
|
request.transaction = null;
|
|
if (upgrade.aborted) {
|
|
this.stores = before;
|
|
database.version = oldVersion;
|
|
request.error = new DOMException(
|
|
"Upgrade transaction aborted.",
|
|
"AbortError",
|
|
);
|
|
request.onerror?.(new Event("error"));
|
|
return;
|
|
}
|
|
this.databaseVersion = targetVersion;
|
|
}
|
|
database.version = this.databaseVersion;
|
|
request.result = database as unknown as IDBDatabase;
|
|
request.onsuccess?.(new Event("success"));
|
|
}
|
|
}
|