Files

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"));
}
}