Merge pull request #456 from vector-im/snowpack-ts-storage-1
Snowpack + Typescript conversion (Part 1)
This commit is contained in:
commit
cd900ab842
18 changed files with 374 additions and 293 deletions
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {KeyLimits} from "../../storage/common.js";
|
||||
import {KeyLimits} from "../../storage/common";
|
||||
|
||||
// key for events in the timelineEvents store
|
||||
export class EventKey {
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import {BaseEntry} from "./BaseEntry";
|
||||
import {Direction} from "../Direction.js";
|
||||
import {isValidFragmentId} from "../common.js";
|
||||
import {KeyLimits} from "../../../storage/common.js";
|
||||
import {KeyLimits} from "../../../storage/common";
|
||||
|
||||
export class FragmentBoundaryEntry extends BaseEntry {
|
||||
constructor(fragment, isFragmentStart, fragmentIdComparer) {
|
||||
|
|
|
@ -14,34 +14,34 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
export const STORE_NAMES = Object.freeze([
|
||||
"session",
|
||||
"roomState",
|
||||
"roomSummary",
|
||||
"archivedRoomSummary",
|
||||
"invites",
|
||||
"roomMembers",
|
||||
"timelineEvents",
|
||||
"timelineRelations",
|
||||
"timelineFragments",
|
||||
"pendingEvents",
|
||||
"userIdentities",
|
||||
"deviceIdentities",
|
||||
"olmSessions",
|
||||
"inboundGroupSessions",
|
||||
"outboundGroupSessions",
|
||||
"groupSessionDecryptions",
|
||||
"operations",
|
||||
"accountData",
|
||||
]);
|
||||
export enum StoreNames {
|
||||
session = "session",
|
||||
roomState = "roomState",
|
||||
roomSummary = "roomSummary",
|
||||
archivedRoomSummary = "archivedRoomSummary",
|
||||
invites = "invites",
|
||||
roomMembers = "roomMembers",
|
||||
timelineEvents = "timelineEvents",
|
||||
timelineRelations = "timelineRelations",
|
||||
timelineFragments = "timelineFragments",
|
||||
pendingEvents = "pendingEvents",
|
||||
userIdentities = "userIdentities",
|
||||
deviceIdentities = "deviceIdentities",
|
||||
olmSessions = "olmSessions",
|
||||
inboundGroupSessions = "inboundGroupSessions",
|
||||
outboundGroupSessions = "outboundGroupSessions",
|
||||
groupSessionDecryptions = "groupSessionDecryptions",
|
||||
operations = "operations",
|
||||
accountData = "accountData",
|
||||
}
|
||||
|
||||
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {
|
||||
nameMap[name] = name;
|
||||
return nameMap;
|
||||
}, {}));
|
||||
export const STORE_NAMES: Readonly<StoreNames[]> = Object.values(StoreNames);
|
||||
|
||||
export class StorageError extends Error {
|
||||
constructor(message, cause) {
|
||||
errcode?: string;
|
||||
cause: Error | null;
|
||||
|
||||
constructor(message: string, cause: Error | null = null) {
|
||||
super(message);
|
||||
if (cause) {
|
||||
this.errcode = cause.name;
|
||||
|
@ -49,23 +49,23 @@ export class StorageError extends Error {
|
|||
this.cause = cause;
|
||||
}
|
||||
|
||||
get name() {
|
||||
get name(): string {
|
||||
return "StorageError";
|
||||
}
|
||||
}
|
||||
|
||||
export const KeyLimits = {
|
||||
get minStorageKey() {
|
||||
get minStorageKey(): number {
|
||||
// for indexeddb, we use unsigned 32 bit integers as keys
|
||||
return 0;
|
||||
},
|
||||
|
||||
get middleStorageKey() {
|
||||
get middleStorageKey(): number {
|
||||
// for indexeddb, we use unsigned 32 bit integers as keys
|
||||
return 0x7FFFFFFF;
|
||||
},
|
||||
|
||||
get maxStorageKey() {
|
||||
get maxStorageKey(): number {
|
||||
// for indexeddb, we use unsigned 32 bit integers as keys
|
||||
return 0xFFFFFFFF;
|
||||
}
|
|
@ -14,14 +14,29 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {iterateCursor, reqAsPromise} from "./utils.js";
|
||||
import {iterateCursor, DONE, NOT_DONE, reqAsPromise} from "./utils";
|
||||
|
||||
export class QueryTarget {
|
||||
constructor(target) {
|
||||
type Reducer<A,B> = (acc: B, val: A) => B
|
||||
|
||||
export type IDBQuery = IDBValidKey | IDBKeyRange | undefined | null
|
||||
|
||||
interface QueryTargetInterface<T> {
|
||||
openCursor(range?: IDBQuery, direction?: IDBCursorDirection | undefined): IDBRequest<IDBCursorWithValue | null>;
|
||||
openKeyCursor(range?: IDBQuery, direction?: IDBCursorDirection | undefined): IDBRequest<IDBCursor | null>;
|
||||
supports(method: string): boolean;
|
||||
keyPath: string | string[];
|
||||
get(key: IDBValidKey | IDBKeyRange): IDBRequest<T | null>;
|
||||
getKey(key: IDBValidKey | IDBKeyRange): IDBRequest<IDBValidKey | undefined>;
|
||||
}
|
||||
|
||||
export class QueryTarget<T> {
|
||||
protected _target: QueryTargetInterface<T>;
|
||||
|
||||
constructor(target: QueryTargetInterface<T>) {
|
||||
this._target = target;
|
||||
}
|
||||
|
||||
_openCursor(range, direction) {
|
||||
_openCursor(range?: IDBQuery, direction?: IDBCursorDirection): IDBRequest<IDBCursorWithValue | null> {
|
||||
if (range && direction) {
|
||||
return this._target.openCursor(range, direction);
|
||||
} else if (range) {
|
||||
|
@ -33,95 +48,99 @@ export class QueryTarget {
|
|||
}
|
||||
}
|
||||
|
||||
supports(methodName) {
|
||||
supports(methodName: string): boolean {
|
||||
return this._target.supports(methodName);
|
||||
}
|
||||
|
||||
get(key) {
|
||||
get(key: IDBValidKey | IDBKeyRange): Promise<T | null> {
|
||||
return reqAsPromise(this._target.get(key));
|
||||
}
|
||||
|
||||
getKey(key) {
|
||||
getKey(key: IDBValidKey | IDBKeyRange): Promise<IDBValidKey | undefined> {
|
||||
if (this._target.supports("getKey")) {
|
||||
return reqAsPromise(this._target.getKey(key));
|
||||
} else {
|
||||
return reqAsPromise(this._target.get(key)).then(value => {
|
||||
if (value) {
|
||||
return value[this._target.keyPath];
|
||||
let keyPath = this._target.keyPath;
|
||||
if (typeof keyPath === "string") {
|
||||
keyPath = [keyPath];
|
||||
}
|
||||
return keyPath.reduce((obj, key) => obj[key], value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reduce(range, reducer, initialValue) {
|
||||
reduce<B>(range: IDBQuery, reducer: Reducer<T,B>, initialValue: B): Promise<boolean> {
|
||||
return this._reduce(range, reducer, initialValue, "next");
|
||||
}
|
||||
|
||||
reduceReverse(range, reducer, initialValue) {
|
||||
reduceReverse<B>(range: IDBQuery, reducer: Reducer<T,B>, initialValue: B): Promise<boolean> {
|
||||
return this._reduce(range, reducer, initialValue, "prev");
|
||||
}
|
||||
|
||||
selectLimit(range, amount) {
|
||||
selectLimit(range: IDBQuery, amount: number): Promise<T[]> {
|
||||
return this._selectLimit(range, amount, "next");
|
||||
}
|
||||
|
||||
selectLimitReverse(range, amount) {
|
||||
selectLimitReverse(range: IDBQuery, amount: number): Promise<T[]> {
|
||||
return this._selectLimit(range, amount, "prev");
|
||||
}
|
||||
|
||||
selectWhile(range, predicate) {
|
||||
selectWhile(range: IDBQuery, predicate: (v: T) => boolean): Promise<T[]> {
|
||||
return this._selectWhile(range, predicate, "next");
|
||||
}
|
||||
|
||||
selectWhileReverse(range, predicate) {
|
||||
selectWhileReverse(range: IDBQuery, predicate: (v: T) => boolean): Promise<T[]> {
|
||||
return this._selectWhile(range, predicate, "prev");
|
||||
}
|
||||
|
||||
async selectAll(range, direction) {
|
||||
async selectAll(range?: IDBQuery, direction?: IDBCursorDirection): Promise<T[]> {
|
||||
const cursor = this._openCursor(range, direction);
|
||||
const results = [];
|
||||
await iterateCursor(cursor, (value) => {
|
||||
const results: T[] = [];
|
||||
await iterateCursor<T>(cursor, (value) => {
|
||||
results.push(value);
|
||||
return {done: false};
|
||||
return NOT_DONE;
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
selectFirst(range) {
|
||||
selectFirst(range: IDBQuery): Promise<T | undefined> {
|
||||
return this._find(range, () => true, "next");
|
||||
}
|
||||
|
||||
selectLast(range) {
|
||||
selectLast(range: IDBQuery): Promise<T | undefined> {
|
||||
return this._find(range, () => true, "prev");
|
||||
}
|
||||
|
||||
find(range, predicate) {
|
||||
find(range: IDBQuery, predicate: (v: T) => boolean): Promise<T | undefined> {
|
||||
return this._find(range, predicate, "next");
|
||||
}
|
||||
|
||||
findReverse(range, predicate) {
|
||||
findReverse(range: IDBQuery, predicate: (v : T) => boolean): Promise<T | undefined> {
|
||||
return this._find(range, predicate, "prev");
|
||||
}
|
||||
|
||||
async findMaxKey(range) {
|
||||
async findMaxKey(range: IDBQuery): Promise<IDBValidKey | undefined> {
|
||||
const cursor = this._target.openKeyCursor(range, "prev");
|
||||
let maxKey;
|
||||
await iterateCursor(cursor, (_, key) => {
|
||||
maxKey = key;
|
||||
return {done: true};
|
||||
return DONE;
|
||||
});
|
||||
return maxKey;
|
||||
}
|
||||
|
||||
|
||||
async iterateValues(range, callback) {
|
||||
async iterateValues(range: IDBQuery, callback: (val: T, key: IDBValidKey, cur: IDBCursorWithValue) => boolean): Promise<void> {
|
||||
const cursor = this._target.openCursor(range, "next");
|
||||
await iterateCursor(cursor, (value, key, cur) => {
|
||||
await iterateCursor<T>(cursor, (value, key, cur) => {
|
||||
return {done: callback(value, key, cur)};
|
||||
});
|
||||
}
|
||||
|
||||
async iterateKeys(range, callback) {
|
||||
async iterateKeys(range: IDBQuery, callback: (key: IDBValidKey, cur: IDBCursor) => boolean): Promise<void> {
|
||||
const cursor = this._target.openKeyCursor(range, "next");
|
||||
await iterateCursor(cursor, (_, key, cur) => {
|
||||
return {done: callback(key, cur)};
|
||||
|
@ -134,7 +153,7 @@ export class QueryTarget {
|
|||
* If the callback returns true, the search is halted and callback won't be called again.
|
||||
* `callback` is called with the same instances of the key as given in `keys`, so direct comparison can be used.
|
||||
*/
|
||||
async findExistingKeys(keys, backwards, callback) {
|
||||
async findExistingKeys(keys: IDBValidKey[], backwards: boolean, callback: (key: IDBValidKey, found: boolean) => boolean): Promise<void> {
|
||||
const direction = backwards ? "prev" : "next";
|
||||
const compareKeys = (a, b) => backwards ? -indexedDB.cmp(a, b) : indexedDB.cmp(a, b);
|
||||
const sortedKeys = keys.slice().sort(compareKeys);
|
||||
|
@ -154,7 +173,10 @@ export class QueryTarget {
|
|||
++i;
|
||||
}
|
||||
const done = consumerDone || i >= sortedKeys.length;
|
||||
const jumpTo = !done && sortedKeys[i];
|
||||
let jumpTo;
|
||||
if (!done) {
|
||||
jumpTo = sortedKeys[i];
|
||||
}
|
||||
return {done, jumpTo};
|
||||
});
|
||||
// report null for keys we didn't to at the end
|
||||
|
@ -164,25 +186,25 @@ export class QueryTarget {
|
|||
}
|
||||
}
|
||||
|
||||
_reduce(range, reducer, initialValue, direction) {
|
||||
_reduce<B>(range: IDBQuery, reducer: (reduced: B, value: T) => B, initialValue: B, direction: IDBCursorDirection): Promise<boolean> {
|
||||
let reducedValue = initialValue;
|
||||
const cursor = this._openCursor(range, direction);
|
||||
return iterateCursor(cursor, (value) => {
|
||||
return iterateCursor<T>(cursor, (value) => {
|
||||
reducedValue = reducer(reducedValue, value);
|
||||
return {done: false};
|
||||
return NOT_DONE;
|
||||
});
|
||||
}
|
||||
|
||||
_selectLimit(range, amount, direction) {
|
||||
_selectLimit(range: IDBQuery, amount: number, direction: IDBCursorDirection): Promise<T[]> {
|
||||
return this._selectUntil(range, (results) => {
|
||||
return results.length === amount;
|
||||
}, direction);
|
||||
}
|
||||
|
||||
async _selectUntil(range, predicate, direction) {
|
||||
async _selectUntil(range: IDBQuery, predicate: (vs: T[], v: T) => boolean, direction: IDBCursorDirection): Promise<T[]> {
|
||||
const cursor = this._openCursor(range, direction);
|
||||
const results = [];
|
||||
await iterateCursor(cursor, (value) => {
|
||||
const results: T[] = [];
|
||||
await iterateCursor<T>(cursor, (value) => {
|
||||
results.push(value);
|
||||
return {done: predicate(results, value)};
|
||||
});
|
||||
|
@ -190,10 +212,10 @@ export class QueryTarget {
|
|||
}
|
||||
|
||||
// allows you to fetch one too much that won't get added when the predicate fails
|
||||
async _selectWhile(range, predicate, direction) {
|
||||
async _selectWhile(range: IDBQuery, predicate: (v: T) => boolean, direction: IDBCursorDirection): Promise<T[]> {
|
||||
const cursor = this._openCursor(range, direction);
|
||||
const results = [];
|
||||
await iterateCursor(cursor, (value) => {
|
||||
const results: T[] = [];
|
||||
await iterateCursor<T>(cursor, (value) => {
|
||||
const passesPredicate = predicate(value);
|
||||
if (passesPredicate) {
|
||||
results.push(value);
|
||||
|
@ -203,18 +225,18 @@ export class QueryTarget {
|
|||
return results;
|
||||
}
|
||||
|
||||
async iterateWhile(range, predicate) {
|
||||
async iterateWhile(range: IDBQuery, predicate: (v: T) => boolean): Promise<void> {
|
||||
const cursor = this._openCursor(range, "next");
|
||||
await iterateCursor(cursor, (value) => {
|
||||
await iterateCursor<T>(cursor, (value) => {
|
||||
const passesPredicate = predicate(value);
|
||||
return {done: !passesPredicate};
|
||||
});
|
||||
}
|
||||
|
||||
async _find(range, predicate, direction) {
|
||||
async _find(range: IDBQuery, predicate: (v: T) => boolean, direction: IDBCursorDirection): Promise<T | undefined> {
|
||||
const cursor = this._openCursor(range, direction);
|
||||
let result;
|
||||
const found = await iterateCursor(cursor, (value) => {
|
||||
const found = await iterateCursor<T>(cursor, (value) => {
|
||||
const found = predicate(value);
|
||||
if (found) {
|
||||
result = value;
|
|
@ -15,8 +15,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {Transaction} from "./Transaction.js";
|
||||
import { STORE_NAMES, StorageError } from "../common.js";
|
||||
import { reqAsPromise } from "./utils.js";
|
||||
import { STORE_NAMES, StoreNames, StorageError } from "../common";
|
||||
import { reqAsPromise } from "./utils";
|
||||
|
||||
const WEBKITEARLYCLOSETXNBUG_BOGUS_KEY = "782rh281re38-boguskey";
|
||||
|
||||
|
@ -25,11 +25,7 @@ export class Storage {
|
|||
this._db = idbDatabase;
|
||||
this._IDBKeyRange = IDBKeyRange;
|
||||
this._hasWebkitEarlyCloseTxnBug = hasWebkitEarlyCloseTxnBug;
|
||||
const nameMap = STORE_NAMES.reduce((nameMap, name) => {
|
||||
nameMap[name] = name;
|
||||
return nameMap;
|
||||
}, {});
|
||||
this.storeNames = Object.freeze(nameMap);
|
||||
this.storeNames = StoreNames;
|
||||
}
|
||||
|
||||
_validateStoreNames(storeNames) {
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {Storage} from "./Storage.js";
|
||||
import { openDatabase, reqAsPromise } from "./utils.js";
|
||||
import { openDatabase, reqAsPromise } from "./utils";
|
||||
import { exportSession, importSession } from "./export.js";
|
||||
import { schema } from "./schema.js";
|
||||
import { detectWebkitEarlyCloseTxnBug } from "./quirks.js";
|
||||
|
|
|
@ -1,164 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {QueryTarget} from "./QueryTarget.js";
|
||||
import {IDBRequestAttemptError} from "./error.js";
|
||||
|
||||
const LOG_REQUESTS = false;
|
||||
|
||||
function logRequest(method, params, source) {
|
||||
const storeName = source?.name;
|
||||
const databaseName = source?.transaction?.db?.name;
|
||||
console.info(`${databaseName}.${storeName}.${method}(${params.map(p => JSON.stringify(p)).join(", ")})`);
|
||||
}
|
||||
|
||||
class QueryTargetWrapper {
|
||||
constructor(qt) {
|
||||
this._qt = qt;
|
||||
}
|
||||
|
||||
get keyPath() {
|
||||
if (this._qt.objectStore) {
|
||||
return this._qt.objectStore.keyPath;
|
||||
} else {
|
||||
return this._qt.keyPath;
|
||||
}
|
||||
}
|
||||
|
||||
supports(methodName) {
|
||||
return !!this._qt[methodName];
|
||||
}
|
||||
|
||||
openKeyCursor(...params) {
|
||||
try {
|
||||
// not supported on Edge 15
|
||||
if (!this._qt.openKeyCursor) {
|
||||
LOG_REQUESTS && logRequest("openCursor", params, this._qt);
|
||||
return this.openCursor(...params);
|
||||
}
|
||||
LOG_REQUESTS && logRequest("openKeyCursor", params, this._qt);
|
||||
return this._qt.openKeyCursor(...params);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("openKeyCursor", this._qt, err, params);
|
||||
}
|
||||
}
|
||||
|
||||
openCursor(...params) {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("openCursor", params, this._qt);
|
||||
return this._qt.openCursor(...params);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("openCursor", this._qt, err, params);
|
||||
}
|
||||
}
|
||||
|
||||
put(...params) {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("put", params, this._qt);
|
||||
return this._qt.put(...params);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("put", this._qt, err, params);
|
||||
}
|
||||
}
|
||||
|
||||
add(...params) {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("add", params, this._qt);
|
||||
return this._qt.add(...params);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("add", this._qt, err, params);
|
||||
}
|
||||
}
|
||||
|
||||
get(...params) {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("get", params, this._qt);
|
||||
return this._qt.get(...params);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("get", this._qt, err, params);
|
||||
}
|
||||
}
|
||||
|
||||
getKey(...params) {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("getKey", params, this._qt);
|
||||
return this._qt.getKey(...params);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("getKey", this._qt, err, params);
|
||||
}
|
||||
}
|
||||
|
||||
delete(...params) {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("delete", params, this._qt);
|
||||
return this._qt.delete(...params);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("delete", this._qt, err, params);
|
||||
}
|
||||
}
|
||||
|
||||
index(...params) {
|
||||
try {
|
||||
return this._qt.index(...params);
|
||||
} catch(err) {
|
||||
// TODO: map to different error? this is not a request
|
||||
throw new IDBRequestAttemptError("index", this._qt, err, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Store extends QueryTarget {
|
||||
constructor(idbStore, transaction) {
|
||||
super(new QueryTargetWrapper(idbStore));
|
||||
this._transaction = transaction;
|
||||
}
|
||||
|
||||
get IDBKeyRange() {
|
||||
return this._transaction.IDBKeyRange;
|
||||
}
|
||||
|
||||
get _idbStore() {
|
||||
return this._target;
|
||||
}
|
||||
|
||||
index(indexName) {
|
||||
return new QueryTarget(new QueryTargetWrapper(this._idbStore.index(indexName)));
|
||||
}
|
||||
|
||||
put(value) {
|
||||
// If this request fails, the error will bubble up to the transaction and abort it,
|
||||
// which is the behaviour we want. Therefore, it is ok to not create a promise for this
|
||||
// request and await it.
|
||||
//
|
||||
// Perhaps at some later point, we will want to handle an error (like ConstraintError) for
|
||||
// individual write requests. In that case, we should add a method that returns a promise (e.g. putAndObserve)
|
||||
// and call preventDefault on the event to prevent it from aborting the transaction
|
||||
//
|
||||
// Note that this can still throw synchronously, like it does for TransactionInactiveError,
|
||||
// see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept
|
||||
this._idbStore.put(value);
|
||||
}
|
||||
|
||||
add(value) {
|
||||
// ok to not monitor result of request, see comment in `put`.
|
||||
this._idbStore.add(value);
|
||||
}
|
||||
|
||||
delete(keyOrKeyRange) {
|
||||
// ok to not monitor result of request, see comment in `put`.
|
||||
this._idbStore.delete(keyOrKeyRange);
|
||||
}
|
||||
}
|
174
src/matrix/storage/idb/Store.ts
Normal file
174
src/matrix/storage/idb/Store.ts
Normal file
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {QueryTarget, IDBQuery} from "./QueryTarget";
|
||||
import {IDBRequestAttemptError} from "./error";
|
||||
import {reqAsPromise} from "./utils";
|
||||
import {Transaction} from "./Transaction";
|
||||
|
||||
const LOG_REQUESTS = false;
|
||||
|
||||
function logRequest(method: string, params: any[], source: any): void {
|
||||
const storeName = source?.name;
|
||||
const databaseName = source?.transaction?.db?.name;
|
||||
console.info(`${databaseName}.${storeName}.${method}(${params.map(p => JSON.stringify(p)).join(", ")})`);
|
||||
}
|
||||
|
||||
class QueryTargetWrapper<T> {
|
||||
private _qt: IDBIndex | IDBObjectStore;
|
||||
|
||||
constructor(qt: IDBIndex | IDBObjectStore) {
|
||||
this._qt = qt;
|
||||
}
|
||||
|
||||
get keyPath(): string | string[] {
|
||||
return this._qtStore.keyPath;
|
||||
}
|
||||
|
||||
get _qtStore(): IDBObjectStore {
|
||||
if ("objectStore" in this._qt) {
|
||||
return this._qt.objectStore;
|
||||
}
|
||||
return this._qt;
|
||||
}
|
||||
|
||||
supports(methodName: string): boolean {
|
||||
return !!this._qt[methodName];
|
||||
}
|
||||
|
||||
openKeyCursor(range?: IDBQuery, direction?: IDBCursorDirection | undefined): IDBRequest<IDBCursor | null> {
|
||||
try {
|
||||
// not supported on Edge 15
|
||||
if (!this._qt.openKeyCursor) {
|
||||
LOG_REQUESTS && logRequest("openCursor", [range, direction], this._qt);
|
||||
return this.openCursor(range, direction);
|
||||
}
|
||||
LOG_REQUESTS && logRequest("openKeyCursor", [range, direction], this._qt);
|
||||
return this._qt.openKeyCursor(range, direction)
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("openKeyCursor", this._qt, err, [range, direction]);
|
||||
}
|
||||
}
|
||||
|
||||
openCursor(range?: IDBQuery, direction?: IDBCursorDirection | undefined): IDBRequest<IDBCursorWithValue | null> {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("openCursor", [], this._qt);
|
||||
return this._qt.openCursor(range, direction)
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("openCursor", this._qt, err, [range, direction]);
|
||||
}
|
||||
}
|
||||
|
||||
put(item: T, key?: IDBValidKey | undefined): IDBRequest<IDBValidKey> {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("put", [item, key], this._qt);
|
||||
return this._qtStore.put(item, key);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("put", this._qt, err, [item, key]);
|
||||
}
|
||||
}
|
||||
|
||||
add(item: T, key?: IDBValidKey | undefined): IDBRequest<IDBValidKey> {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("add", [item, key], this._qt);
|
||||
return this._qtStore.add(item, key);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("add", this._qt, err, [item, key]);
|
||||
}
|
||||
}
|
||||
|
||||
get(key: IDBValidKey | IDBKeyRange): IDBRequest<T | null> {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("get", [key], this._qt);
|
||||
return this._qt.get(key);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("get", this._qt, err, [key]);
|
||||
}
|
||||
}
|
||||
|
||||
getKey(key: IDBValidKey | IDBKeyRange): IDBRequest<IDBValidKey | undefined> {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("getKey", [key], this._qt);
|
||||
return this._qt.getKey(key)
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("getKey", this._qt, err, [key]);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: IDBValidKey | IDBKeyRange): IDBRequest<undefined> {
|
||||
try {
|
||||
LOG_REQUESTS && logRequest("delete", [key], this._qt);
|
||||
return this._qtStore.delete(key);
|
||||
} catch(err) {
|
||||
throw new IDBRequestAttemptError("delete", this._qt, err, [key]);
|
||||
}
|
||||
}
|
||||
|
||||
index(name: string): IDBIndex {
|
||||
try {
|
||||
return this._qtStore.index(name);
|
||||
} catch(err) {
|
||||
// TODO: map to different error? this is not a request
|
||||
throw new IDBRequestAttemptError("index", this._qt, err, [name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Store<T> extends QueryTarget<T> {
|
||||
private _transaction: Transaction;
|
||||
|
||||
constructor(idbStore: IDBObjectStore, transaction: Transaction) {
|
||||
super(new QueryTargetWrapper<T>(idbStore));
|
||||
this._transaction = transaction;
|
||||
}
|
||||
|
||||
get IDBKeyRange() {
|
||||
// @ts-ignore
|
||||
return this._transaction.IDBKeyRange;
|
||||
}
|
||||
|
||||
get _idbStore(): QueryTargetWrapper<T> {
|
||||
return (this._target as QueryTargetWrapper<T>);
|
||||
}
|
||||
|
||||
index(indexName: string): QueryTarget<T> {
|
||||
return new QueryTarget<T>(new QueryTargetWrapper<T>(this._idbStore.index(indexName)));
|
||||
}
|
||||
|
||||
put(value: T): Promise<IDBValidKey> {
|
||||
// If this request fails, the error will bubble up to the transaction and abort it,
|
||||
// which is the behaviour we want. Therefore, it is ok to not create a promise for this
|
||||
// request and await it.
|
||||
//
|
||||
// Perhaps at some later point, we will want to handle an error (like ConstraintError) for
|
||||
// individual write requests. In that case, we should add a method that returns a promise (e.g. putAndObserve)
|
||||
// and call preventDefault on the event to prevent it from aborting the transaction
|
||||
//
|
||||
// Note that this can still throw synchronously, like it does for TransactionInactiveError,
|
||||
// see https://www.w3.org/TR/IndexedDB-2/#transaction-lifetime-concept
|
||||
return reqAsPromise(this._idbStore.put(value));
|
||||
}
|
||||
|
||||
add(value: T): Promise<IDBValidKey> {
|
||||
// ok to not monitor result of request, see comment in `put`.
|
||||
return reqAsPromise(this._idbStore.add(value));
|
||||
}
|
||||
|
||||
delete(keyOrKeyRange: IDBValidKey | IDBKeyRange): Promise<undefined> {
|
||||
// ok to not monitor result of request, see comment in `put`.
|
||||
return reqAsPromise(this._idbStore.delete(keyOrKeyRange));
|
||||
}
|
||||
}
|
|
@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {txnAsPromise} from "./utils.js";
|
||||
import {StorageError} from "../common.js";
|
||||
import {Store} from "./Store.js";
|
||||
import {txnAsPromise} from "./utils";
|
||||
import {StorageError} from "../common";
|
||||
import {Store} from "./Store";
|
||||
import {SessionStore} from "./stores/SessionStore.js";
|
||||
import {RoomSummaryStore} from "./stores/RoomSummaryStore.js";
|
||||
import {InviteStore} from "./stores/InviteStore.js";
|
||||
|
|
|
@ -15,12 +15,28 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { StorageError } from "../common.js";
|
||||
import { StorageError } from "../common";
|
||||
|
||||
function _sourceName(source: IDBIndex | IDBObjectStore): string {
|
||||
return "objectStore" in source ?
|
||||
`${source.objectStore.name}.${source.name}` :
|
||||
source.name;
|
||||
}
|
||||
|
||||
function _sourceDatabase(source: IDBIndex | IDBObjectStore): string {
|
||||
return "objectStore" in source ?
|
||||
source.objectStore?.transaction?.db?.name :
|
||||
source.transaction?.db?.name;
|
||||
}
|
||||
|
||||
export class IDBError extends StorageError {
|
||||
constructor(message, source, cause) {
|
||||
const storeName = source?.name || "<unknown store>";
|
||||
const databaseName = source?.transaction?.db?.name || "<unknown db>";
|
||||
storeName: string;
|
||||
databaseName: string;
|
||||
|
||||
constructor(message: string, sourceOrCursor: IDBIndex | IDBCursor | IDBObjectStore, cause: DOMException | null = null) {
|
||||
const source = "source" in sourceOrCursor ? sourceOrCursor.source : sourceOrCursor;
|
||||
const storeName = _sourceName(source);
|
||||
const databaseName = _sourceDatabase(source);
|
||||
let fullMessage = `${message} on ${databaseName}.${storeName}`;
|
||||
if (cause) {
|
||||
fullMessage += ": ";
|
||||
|
@ -41,7 +57,7 @@ export class IDBError extends StorageError {
|
|||
}
|
||||
|
||||
export class IDBRequestError extends IDBError {
|
||||
constructor(request, message = "IDBRequest failed") {
|
||||
constructor(request: IDBRequest, message: string = "IDBRequest failed") {
|
||||
const source = request.source;
|
||||
const cause = request.error;
|
||||
super(message, source, cause);
|
||||
|
@ -49,7 +65,7 @@ export class IDBRequestError extends IDBError {
|
|||
}
|
||||
|
||||
export class IDBRequestAttemptError extends IDBError {
|
||||
constructor(method, source, cause, params) {
|
||||
constructor(method: string, source: IDBIndex | IDBObjectStore, cause: DOMException, params: any[]) {
|
||||
super(`${method}(${params.map(p => JSON.stringify(p)).join(", ")}) failed`, source, cause);
|
||||
}
|
||||
}
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { iterateCursor, txnAsPromise } from "./utils.js";
|
||||
import { STORE_NAMES } from "../common.js";
|
||||
import { iterateCursor, txnAsPromise } from "./utils";
|
||||
import { STORE_NAMES } from "../common";
|
||||
|
||||
export async function exportSession(db) {
|
||||
const NOT_DONE = {done: false};
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
|
||||
import {openDatabase, txnAsPromise, reqAsPromise} from "./utils.js";
|
||||
import {openDatabase, txnAsPromise, reqAsPromise} from "./utils";
|
||||
|
||||
// filed as https://bugs.webkit.org/show_bug.cgi?id=222746
|
||||
export async function detectWebkitEarlyCloseTxnBug(idbFactory) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {iterateCursor, reqAsPromise} from "./utils.js";
|
||||
import {iterateCursor, reqAsPromise} from "./utils";
|
||||
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
|
||||
import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js";
|
||||
import {RoomMemberStore} from "./stores/RoomMemberStore.js";
|
||||
|
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { encodeUint32, decodeUint32 } from "../utils.js";
|
||||
import {KeyLimits} from "../../common.js";
|
||||
import { encodeUint32, decodeUint32 } from "../utils";
|
||||
import {KeyLimits} from "../../common";
|
||||
|
||||
function encodeKey(roomId, queueIndex) {
|
||||
return `${roomId}|${encodeUint32(queueIndex)}`;
|
||||
|
|
|
@ -15,9 +15,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {EventKey} from "../../../room/timeline/EventKey.js";
|
||||
import { StorageError } from "../../common.js";
|
||||
import { encodeUint32 } from "../utils.js";
|
||||
import {KeyLimits} from "../../common.js";
|
||||
import { StorageError } from "../../common";
|
||||
import { encodeUint32 } from "../utils";
|
||||
import {KeyLimits} from "../../common";
|
||||
|
||||
function encodeKey(roomId, fragmentId, eventIndex) {
|
||||
return `${roomId}|${encodeUint32(fragmentId)}|${encodeUint32(eventIndex)}`;
|
||||
|
|
|
@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { StorageError } from "../../common.js";
|
||||
import {KeyLimits} from "../../common.js";
|
||||
import { encodeUint32 } from "../utils.js";
|
||||
import { StorageError } from "../../common";
|
||||
import {KeyLimits} from "../../common";
|
||||
import { encodeUint32 } from "../utils";
|
||||
|
||||
function encodeKey(roomId, fragmentId) {
|
||||
return `${roomId}|${encodeUint32(fragmentId)}`;
|
||||
|
|
|
@ -15,17 +15,20 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IDBRequestError } from "./error.js";
|
||||
import { StorageError } from "../common.js";
|
||||
import { IDBRequestError } from "./error";
|
||||
import { StorageError } from "../common";
|
||||
|
||||
let needsSyncPromise = false;
|
||||
|
||||
export const DONE = { done: true }
|
||||
export const NOT_DONE = { done: false }
|
||||
|
||||
/* should be called on legacy platforms to see
|
||||
if transactions close before draining the microtask queue (IE11 on Windows 7).
|
||||
If this is the case, promises need to be resolved
|
||||
synchronously from the idb request handler to prevent the transaction from closing prematurely.
|
||||
*/
|
||||
export async function checkNeedsSyncPromise() {
|
||||
export async function checkNeedsSyncPromise(): Promise<boolean> {
|
||||
// important to have it turned off while doing the test,
|
||||
// otherwise reqAsPromise would not fail
|
||||
needsSyncPromise = false;
|
||||
|
@ -49,26 +52,29 @@ export async function checkNeedsSyncPromise() {
|
|||
}
|
||||
|
||||
// storage keys are defined to be unsigned 32bit numbers in KeyLimits, which is assumed by idb
|
||||
export function encodeUint32(n) {
|
||||
export function encodeUint32(n: number): string {
|
||||
const hex = n.toString(16);
|
||||
return "0".repeat(8 - hex.length) + hex;
|
||||
}
|
||||
|
||||
// used for logs where timestamp is part of key, which is larger than 32 bit
|
||||
export function encodeUint64(n) {
|
||||
export function encodeUint64(n: number): string {
|
||||
const hex = n.toString(16);
|
||||
return "0".repeat(16 - hex.length) + hex;
|
||||
}
|
||||
|
||||
export function decodeUint32(str) {
|
||||
export function decodeUint32(str: string): number {
|
||||
return parseInt(str, 16);
|
||||
}
|
||||
|
||||
export function openDatabase(name, createObjectStore, version, idbFactory = window.indexedDB) {
|
||||
type CreateObjectStore = (db : IDBDatabase, txn: IDBTransaction | null, oldVersion: number, version: number) => any
|
||||
|
||||
export function openDatabase(name: string, createObjectStore: CreateObjectStore, version: number, idbFactory: IDBFactory = window.indexedDB): Promise<IDBDatabase> {
|
||||
const req = idbFactory.open(name, version);
|
||||
req.onupgradeneeded = async (ev) => {
|
||||
const db = ev.target.result;
|
||||
const txn = ev.target.transaction;
|
||||
req.onupgradeneeded = async (ev : IDBVersionChangeEvent) => {
|
||||
const req = ev.target as IDBRequest<IDBDatabase>;
|
||||
const db = req.result;
|
||||
const txn = req.transaction!;
|
||||
const oldVersion = ev.oldVersion;
|
||||
try {
|
||||
await createObjectStore(db, txn, oldVersion, version);
|
||||
|
@ -82,25 +88,28 @@ export function openDatabase(name, createObjectStore, version, idbFactory = wind
|
|||
return reqAsPromise(req);
|
||||
}
|
||||
|
||||
export function reqAsPromise(req) {
|
||||
export function reqAsPromise<T>(req: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.addEventListener("success", event => {
|
||||
resolve(event.target.result);
|
||||
resolve((event.target as IDBRequest<T>).result);
|
||||
// @ts-ignore
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
});
|
||||
req.addEventListener("error", event => {
|
||||
const error = new IDBRequestError(event.target);
|
||||
const error = new IDBRequestError(event.target as IDBRequest<T>);
|
||||
reject(error);
|
||||
// @ts-ignore
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function txnAsPromise(txn) {
|
||||
export function txnAsPromise(txn): Promise<void> {
|
||||
let error;
|
||||
return new Promise((resolve, reject) => {
|
||||
txn.addEventListener("complete", () => {
|
||||
resolve();
|
||||
// @ts-ignore
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
});
|
||||
txn.addEventListener("error", event => {
|
||||
|
@ -119,33 +128,56 @@ export function txnAsPromise(txn) {
|
|||
error = new StorageError(`Transaction on ${dbName} with stores ${storeNames} was aborted.`);
|
||||
}
|
||||
reject(error);
|
||||
// @ts-ignore
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function iterateCursor(cursorRequest, processValue) {
|
||||
/**
|
||||
* This type is rather complicated, but I hope that this is for a good reason. There
|
||||
* are currently two uses for `iterateCursor`: iterating a regular cursor, and iterating
|
||||
* a key-only cursor, which does not have values. These two uses are distinct, and iteration
|
||||
* never stops or starts having a value halfway through.
|
||||
*
|
||||
* Each of the argument functions currently either assumes the value will be there, or that it won't. We thus can't
|
||||
* just accept a function argument `(T | undefined) => { done: boolean }`, since this messes with
|
||||
* the type safety in both cases: the former case will have to check for `undefined`, and
|
||||
* the latter would have an argument that can be `T`, even though it never will.
|
||||
*
|
||||
* So the approach here is to let TypeScript infer and accept (via generics) the type of
|
||||
* the cursor, which is either `IDBCursorWithValue` or `IDBCursor`. Since the type is accepted
|
||||
* via generics, we can actually vary the types of the actual function arguments depending on it.
|
||||
* Thus, when a value is available (an `IDBCursorWithValue` is given), we require a function `(T) => ...`, and when it is not, we require
|
||||
* a function `(undefined) => ...`.
|
||||
*/
|
||||
type CursorIterator<T, I extends IDBCursor> = (value: I extends IDBCursorWithValue ? T : undefined, key: IDBValidKey, cursor: I) => { done: boolean, jumpTo?: IDBValidKey }
|
||||
|
||||
export function iterateCursor<T, I extends IDBCursor = IDBCursorWithValue>(cursorRequest: IDBRequest<I | null>, processValue: CursorIterator<T, I>): Promise<boolean> {
|
||||
// TODO: does cursor already have a value here??
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
cursorRequest.onerror = () => {
|
||||
reject(new IDBRequestError(cursorRequest));
|
||||
// @ts-ignore
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
};
|
||||
// collect results
|
||||
cursorRequest.onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
const cursor = (event.target as IDBRequest<I>).result;
|
||||
if (!cursor) {
|
||||
resolve(false);
|
||||
// @ts-ignore
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
return; // end of results
|
||||
}
|
||||
const result = processValue(cursor.value, cursor.key, cursor);
|
||||
const result = processValue(cursor["value"], cursor.key, cursor);
|
||||
// TODO: don't use object for result and assume it's jumpTo when not === true/false or undefined
|
||||
const done = result?.done;
|
||||
const jumpTo = result?.jumpTo;
|
||||
|
||||
if (done) {
|
||||
resolve(true);
|
||||
// @ts-ignore
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
} else if(jumpTo) {
|
||||
cursor.continue(jumpTo);
|
||||
|
@ -158,16 +190,20 @@ export function iterateCursor(cursorRequest, processValue) {
|
|||
});
|
||||
}
|
||||
|
||||
export async function fetchResults(cursor, isDone) {
|
||||
const results = [];
|
||||
await iterateCursor(cursor, (value) => {
|
||||
type Pred<T> = (value: T) => boolean
|
||||
|
||||
export async function fetchResults<T>(cursor: IDBRequest, isDone: Pred<T[]>): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
await iterateCursor<T>(cursor, (value) => {
|
||||
results.push(value);
|
||||
return {done: isDone(results)};
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function select(db, storeName, toCursor, isDone) {
|
||||
type ToCursor = (store: IDBObjectStore) => IDBRequest
|
||||
|
||||
export async function select<T>(db: IDBDatabase, storeName: string, toCursor: ToCursor, isDone: Pred<T[]>): Promise<T[]> {
|
||||
if (!isDone) {
|
||||
isDone = () => false;
|
||||
}
|
||||
|
@ -180,7 +216,7 @@ export async function select(db, storeName, toCursor, isDone) {
|
|||
return await fetchResults(cursor, isDone);
|
||||
}
|
||||
|
||||
export async function findStoreValue(db, storeName, toCursor, matchesValue) {
|
||||
export async function findStoreValue<T>(db: IDBDatabase, storeName: string, toCursor: ToCursor, matchesValue: Pred<T>): Promise<T> {
|
||||
if (!matchesValue) {
|
||||
matchesValue = () => true;
|
||||
}
|
||||
|
@ -192,11 +228,12 @@ export async function findStoreValue(db, storeName, toCursor, matchesValue) {
|
|||
const store = tx.objectStore(storeName);
|
||||
const cursor = await reqAsPromise(toCursor(store));
|
||||
let match;
|
||||
const matched = await iterateCursor(cursor, (value) => {
|
||||
const matched = await iterateCursor<T>(cursor, (value) => {
|
||||
if (matchesValue(value)) {
|
||||
match = value;
|
||||
return true;
|
||||
return DONE;
|
||||
}
|
||||
return NOT_DONE;
|
||||
});
|
||||
if (!matched) {
|
||||
throw new StorageError("Value not found");
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
// polyfills needed for IE11
|
||||
import Promise from "../../../lib/es6-promise/index.js";
|
||||
import {checkNeedsSyncPromise} from "../../matrix/storage/idb/utils.js";
|
||||
import {checkNeedsSyncPromise} from "../../matrix/storage/idb/utils";
|
||||
|
||||
if (typeof window.Promise === "undefined") {
|
||||
window.Promise = Promise;
|
||||
|
|
Reference in a new issue