wallet-core/packages/idb-bridge/src/MemoryBackend.ts

1466 lines
43 KiB
TypeScript
Raw Normal View History

2019-08-01 23:27:42 +02:00
/*
Copyright 2019 Florian Dold
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.
*/
2019-06-15 22:44:54 +02:00
import {
Backend,
DatabaseConnection,
DatabaseTransaction,
Schema,
RecordStoreRequest,
IndexProperties,
2019-06-21 19:18:36 +02:00
RecordGetRequest,
RecordGetResponse,
ResultLevel,
StoreLevel,
RecordStoreResponse,
2019-06-15 22:44:54 +02:00
} from "./backend-interface";
import structuredClone from "./util/structuredClone";
2019-06-21 19:18:36 +02:00
import {
InvalidStateError,
InvalidAccessError,
ConstraintError,
} from "./util/errors";
2019-08-16 19:05:48 +02:00
import BTree, { ISortedMapF } from "./tree/b+tree";
2019-06-15 22:44:54 +02:00
import compareKeys from "./util/cmp";
import { Key, Value, KeyPath } from "./util/types";
2019-06-21 19:18:36 +02:00
import { StoreKeyResult, makeStoreKeyValue } from "./util/makeStoreKeyValue";
import getIndexKeys from "./util/getIndexKeys";
import openPromise from "./util/openPromise";
import BridgeIDBKeyRange from "./BridgeIDBKeyRange";
2019-06-15 22:44:54 +02:00
enum TransactionLevel {
Disconnected = 0,
Connected = 1,
Read = 2,
Write = 3,
VersionChange = 4,
}
interface ObjectStore {
originalName: string;
modifiedName: string | undefined;
2019-06-21 19:18:36 +02:00
originalData: ISortedMapF<Key, ObjectStoreRecord>;
modifiedData: ISortedMapF<Key, ObjectStoreRecord> | undefined;
2019-06-15 22:44:54 +02:00
deleted: boolean;
originalKeyGenerator: number;
modifiedKeyGenerator: number | undefined;
}
interface Index {
originalName: string;
modifiedName: string | undefined;
2019-06-21 19:18:36 +02:00
originalData: ISortedMapF<Key, IndexRecord>;
modifiedData: ISortedMapF<Key, IndexRecord> | undefined;
2019-06-15 22:44:54 +02:00
deleted: boolean;
}
interface Database {
committedObjectStores: { [name: string]: ObjectStore };
modifiedObjectStores: { [name: string]: ObjectStore };
committedIndexes: { [name: string]: Index };
modifiedIndexes: { [name: string]: Index };
committedSchema: Schema;
/**
* Was the transaction deleted during the running transaction?
*/
deleted: boolean;
txLevel: TransactionLevel;
connectionCookie: string | undefined;
}
2019-08-16 19:05:48 +02:00
interface ObjectStoreDump {
name: string;
keyGenerator: number;
records: ObjectStoreRecord[];
}
interface IndexDump {
name: string;
records: IndexRecord[];
}
interface DatabaseDump {
schema: Schema;
objectStores: { [name: string]: ObjectStoreDump };
indexes: { [name: string]: IndexDump };
}
interface MemoryBackendDump {
databases: { [name: string]: DatabaseDump };
}
2019-06-15 22:44:54 +02:00
interface Connection {
dbName: string;
modifiedSchema: Schema | undefined;
/**
* Has the underlying database been deleted?
*/
deleted: boolean;
/**
* Map from the effective name of an object store during
* the transaction to the real name.
*/
objectStoreMap: { [currentName: string]: ObjectStore };
indexMap: { [currentName: string]: Index };
}
2019-06-21 19:18:36 +02:00
interface IndexRecord {
indexKey: Key;
primaryKeys: Key[];
}
2019-06-15 22:44:54 +02:00
2019-06-21 19:18:36 +02:00
interface ObjectStoreRecord {
primaryKey: Key;
value: Value;
2019-06-15 22:44:54 +02:00
}
2019-06-21 19:18:36 +02:00
class AsyncCondition {
_waitPromise: Promise<void>;
_resolveWaitPromise: () => void;
constructor() {
const op = openPromise<void>();
this._waitPromise = op.promise;
this._resolveWaitPromise = op.resolve;
}
2019-06-15 22:44:54 +02:00
2019-06-21 19:18:36 +02:00
wait(): Promise<void> {
return this._waitPromise;
}
2019-06-15 22:44:54 +02:00
2019-06-21 19:18:36 +02:00
trigger(): void {
this._resolveWaitPromise();
const op = openPromise<void>();
this._waitPromise = op.promise;
this._resolveWaitPromise = op.resolve;
}
}
2019-06-15 22:44:54 +02:00
2019-06-21 19:18:36 +02:00
function nextStoreKey<T>(
forward: boolean,
data: ISortedMapF<Key, ObjectStoreRecord>,
k: Key | undefined,
2019-06-15 22:44:54 +02:00
) {
2019-06-21 19:18:36 +02:00
if (k === undefined || k === null) {
return undefined;
}
const res = forward ? data.nextHigherPair(k) : data.nextLowerPair(k);
if (!res) {
return undefined;
}
return res[1].primaryKey;
}
2019-06-15 22:44:54 +02:00
2019-06-23 22:16:03 +02:00
function furthestKey(
forward: boolean,
key1: Key | undefined,
key2: Key | undefined,
) {
2019-06-21 19:18:36 +02:00
if (key1 === undefined) {
return key2;
}
if (key2 === undefined) {
return key1;
}
const cmpResult = compareKeys(key1, key2);
if (cmpResult === 0) {
// Same result
return key1;
}
if (forward && cmpResult === 1) {
return key1;
}
if (forward && cmpResult === -1) {
return key2;
}
if (!forward && cmpResult === 1) {
return key2;
}
if (!forward && cmpResult === -1) {
return key1;
2019-06-15 22:44:54 +02:00
}
}
/**
* Primitive in-memory backend.
*/
export class MemoryBackend implements Backend {
2019-08-16 19:05:48 +02:00
private databases: { [name: string]: Database } = {};
2019-06-15 22:44:54 +02:00
2019-08-16 19:05:48 +02:00
private connectionIdCounter = 1;
2019-06-15 22:44:54 +02:00
2019-08-16 19:05:48 +02:00
private transactionIdCounter = 1;
2019-06-15 22:44:54 +02:00
/**
* Connections by connection cookie.
*/
2019-08-16 19:05:48 +02:00
private connections: { [name: string]: Connection } = {};
2019-06-15 22:44:54 +02:00
/**
* Connections by transaction (!!) cookie. In this implementation,
* at most one transaction can run at the same time per connection.
*/
2019-08-16 19:05:48 +02:00
private connectionsByTransaction: { [tx: string]: Connection } = {};
2019-06-15 22:44:54 +02:00
/**
* Condition that is triggered whenever a client disconnects.
*/
2019-08-16 19:05:48 +02:00
private disconnectCond: AsyncCondition = new AsyncCondition();
2019-06-15 22:44:54 +02:00
/**
* Conditation that is triggered whenever a transaction finishes.
*/
2019-08-16 19:05:48 +02:00
private transactionDoneCond: AsyncCondition = new AsyncCondition();
afterCommitCallback?: () => Promise<void>;
enableTracing: boolean = false;
/**
* Load the data in this IndexedDB backend from a dump in JSON format.
*
* Must be called before any connections to the database backend have
* been made.
*/
importDump(data: any) {
if (this.transactionIdCounter != 1 || this.connectionIdCounter != 1) {
throw Error(
"data must be imported before first transaction or connection",
);
}
this.databases = {};
for (const dbName of Object.keys(data.databases)) {
const schema = data.databases[dbName].schema;
if (typeof schema !== "object") {
throw Error("DB dump corrupt");
}
const indexes: { [name: string]: Index } = {};
const objectStores: { [name: string]: ObjectStore } = {};
for (const indexName of Object.keys(data.databases[dbName].indexes)) {
const dumpedIndex = data.databases[dbName].indexes[indexName];
const pairs = dumpedIndex.records.map((r: any) => {
return structuredClone([r.indexKey, r]);
});
const indexData: ISortedMapF<Key, IndexRecord> = new BTree(pairs, compareKeys);
const index: Index = {
deleted: false,
modifiedData: undefined,
modifiedName: undefined,
originalName: indexName,
originalData: indexData,
}
indexes[indexName] = index;
}
for (const objectStoreName of Object.keys(data.databases[dbName].objectStores)) {
const dumpedObjectStore = data.databases[dbName].objectStores[objectStoreName];
const pairs = dumpedObjectStore.records.map((r: any) => {
return structuredClone([r.primaryKey, r]);
});
const objectStoreData: ISortedMapF<Key, ObjectStoreRecord> = new BTree(pairs, compareKeys);
const objectStore: ObjectStore = {
deleted: false,
modifiedData: undefined,
modifiedName: undefined,
modifiedKeyGenerator: undefined,
originalData: objectStoreData,
originalName: objectStoreName,
originalKeyGenerator: dumpedObjectStore.keyGenerator,
}
objectStores[objectStoreName] = objectStore;
}
const db: Database = {
committedIndexes: indexes,
deleted: false,
committedObjectStores: objectStores,
committedSchema: structuredClone(schema),
connectionCookie: undefined,
modifiedIndexes: {},
modifiedObjectStores: {},
txLevel: TransactionLevel.Disconnected,
};
this.databases[dbName] = db;
}
}
2019-06-15 22:44:54 +02:00
2019-08-16 19:05:48 +02:00
/**
* Export the contents of the database to JSON.
*
* Only exports data that has been committed.
*/
exportDump(): MemoryBackendDump {
const dbDumps: { [name: string]: DatabaseDump } = {};
for (const dbName of Object.keys(this.databases)) {
const db = this.databases[dbName];
const indexes: { [name: string]: IndexDump } = {};
const objectStores: { [name: string]: ObjectStoreDump } = {};
for (const indexName of Object.keys(db.committedIndexes)) {
const index = db.committedIndexes[indexName];
const indexRecords: IndexRecord[] = [];
index.originalData.forEach((v: IndexRecord) => {
indexRecords.push(structuredClone(v));
});
indexes[indexName] = { name: indexName, records: indexRecords };
}
for (const objectStoreName of Object.keys(db.committedObjectStores)) {
const objectStore = db.committedObjectStores[objectStoreName];
const objectStoreRecords: ObjectStoreRecord[] = [];
objectStore.originalData.forEach((v: ObjectStoreRecord) => {
objectStoreRecords.push(structuredClone(v));
});
objectStores[objectStoreName] = {
name: objectStoreName,
records: objectStoreRecords,
keyGenerator: objectStore.originalKeyGenerator,
};
}
const dbDump: DatabaseDump = {
indexes,
objectStores,
schema: structuredClone(this.databases[dbName].committedSchema),
};
dbDumps[dbName] = dbDump;
}
return { databases: dbDumps };
}
2019-06-21 19:18:36 +02:00
2019-06-15 22:44:54 +02:00
async getDatabases(): Promise<{ name: string; version: number }[]> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log("TRACING: getDatabase");
}
2019-06-15 22:44:54 +02:00
const dbList = [];
for (const name in this.databases) {
dbList.push({
name,
version: this.databases[name].committedSchema.databaseVersion,
});
}
return dbList;
}
async deleteDatabase(tx: DatabaseTransaction, name: string): Promise<void> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log("TRACING: deleteDatabase");
}
2019-06-15 22:44:54 +02:00
const myConn = this.connectionsByTransaction[tx.transactionCookie];
if (!myConn) {
throw Error("no connection associated with transaction");
}
const myDb = this.databases[name];
if (!myDb) {
throw Error("db not found");
}
if (myDb.committedSchema.databaseName !== name) {
throw Error("name does not match");
}
if (myDb.txLevel < TransactionLevel.VersionChange) {
throw new InvalidStateError();
}
if (myDb.connectionCookie !== tx.transactionCookie) {
throw new InvalidAccessError();
}
myDb.deleted = true;
}
async connectDatabase(name: string): Promise<DatabaseConnection> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: connectDatabase(${name})`);
}
2019-06-15 22:44:54 +02:00
const connectionId = this.connectionIdCounter++;
const connectionCookie = `connection-${connectionId}`;
let database = this.databases[name];
if (!database) {
const schema: Schema = {
databaseName: name,
indexes: {},
databaseVersion: 0,
objectStores: {},
};
database = {
committedSchema: schema,
deleted: false,
modifiedIndexes: {},
committedIndexes: {},
committedObjectStores: {},
modifiedObjectStores: {},
txLevel: TransactionLevel.Disconnected,
connectionCookie: undefined,
};
this.databases[name] = database;
}
while (database.txLevel !== TransactionLevel.Disconnected) {
await this.disconnectCond.wait();
}
database.txLevel = TransactionLevel.Connected;
database.connectionCookie = connectionCookie;
2019-06-21 19:18:36 +02:00
const myConn: Connection = {
dbName: name,
deleted: false,
indexMap: Object.assign({}, database.committedIndexes),
objectStoreMap: Object.assign({}, database.committedObjectStores),
modifiedSchema: structuredClone(database.committedSchema),
};
this.connections[connectionCookie] = myConn;
2019-06-15 22:44:54 +02:00
return { connectionCookie };
}
async beginTransaction(
conn: DatabaseConnection,
objectStores: string[],
mode: import("./util/types").TransactionMode,
): Promise<DatabaseTransaction> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: beginTransaction`);
}
2019-06-15 22:44:54 +02:00
const transactionCookie = `tx-${this.transactionIdCounter++}`;
const myConn = this.connections[conn.connectionCookie];
if (!myConn) {
throw Error("connection not found");
}
const myDb = this.databases[myConn.dbName];
if (!myDb) {
throw Error("db not found");
}
while (myDb.txLevel !== TransactionLevel.Connected) {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: beginTransaction -- waiting for others to close`);
}
2019-06-15 22:44:54 +02:00
await this.transactionDoneCond.wait();
}
if (mode === "readonly") {
myDb.txLevel = TransactionLevel.Read;
} else if (mode === "readwrite") {
myDb.txLevel = TransactionLevel.Write;
} else {
throw Error("unsupported transaction mode");
}
this.connectionsByTransaction[transactionCookie] = myConn;
return { transactionCookie };
}
async enterVersionChange(
conn: DatabaseConnection,
newVersion: number,
): Promise<DatabaseTransaction> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: enterVersionChange`);
}
2019-06-15 22:44:54 +02:00
const transactionCookie = `tx-vc-${this.transactionIdCounter++}`;
const myConn = this.connections[conn.connectionCookie];
if (!myConn) {
throw Error("connection not found");
}
const myDb = this.databases[myConn.dbName];
if (!myDb) {
throw Error("db not found");
}
while (myDb.txLevel !== TransactionLevel.Connected) {
await this.transactionDoneCond.wait();
}
myDb.txLevel = TransactionLevel.VersionChange;
this.connectionsByTransaction[transactionCookie] = myConn;
return { transactionCookie };
}
async close(conn: DatabaseConnection): Promise<void> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: close`);
}
2019-06-15 22:44:54 +02:00
const myConn = this.connections[conn.connectionCookie];
if (!myConn) {
throw Error("connection not found - already closed?");
}
if (!myConn.deleted) {
const myDb = this.databases[myConn.dbName];
if (myDb.txLevel != TransactionLevel.Connected) {
throw Error("invalid state");
}
myDb.txLevel = TransactionLevel.Disconnected;
}
delete this.connections[conn.connectionCookie];
2019-06-21 19:18:36 +02:00
this.disconnectCond.trigger();
2019-06-15 22:44:54 +02:00
}
getSchema(dbConn: DatabaseConnection): Schema {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: getSchema`);
}
2019-06-15 22:44:54 +02:00
const myConn = this.connections[dbConn.connectionCookie];
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (myConn.modifiedSchema) {
return myConn.modifiedSchema;
}
return db.committedSchema;
}
renameIndex(
btx: DatabaseTransaction,
oldName: string,
newName: string,
): void {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: renameIndex(?, ${oldName}, ${newName})`);
}
const myConn = this.connectionsByTransaction[btx.transactionCookie];
2019-06-15 22:44:54 +02:00
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.VersionChange) {
throw Error("only allowed in versionchange transaction");
}
let schema = myConn.modifiedSchema;
if (!schema) {
throw Error();
}
if (schema.indexes[newName]) {
throw new Error("new index name already used");
}
if (!schema.indexes[oldName]) {
throw new Error("new index name already used");
}
const index: Index = myConn.indexMap[oldName];
if (!index) {
throw Error("old index missing in connection's index map");
}
schema.indexes[newName] = schema.indexes[newName];
delete schema.indexes[oldName];
for (const storeName in schema.objectStores) {
const store = schema.objectStores[storeName];
store.indexes = store.indexes.map(x => {
if (x == oldName) {
return newName;
} else {
return x;
}
});
}
myConn.indexMap[newName] = index;
delete myConn.indexMap[oldName];
index.modifiedName = newName;
}
deleteIndex(btx: DatabaseTransaction, indexName: string): void {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: deleteIndex(${indexName})`);
}
2019-06-15 22:44:54 +02:00
const myConn = this.connections[btx.transactionCookie];
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.VersionChange) {
throw Error("only allowed in versionchange transaction");
}
let schema = myConn.modifiedSchema;
if (!schema) {
throw Error();
}
if (!schema.indexes[indexName]) {
throw new Error("index does not exist");
}
const index: Index = myConn.indexMap[indexName];
if (!index) {
throw Error("old index missing in connection's index map");
}
index.deleted = true;
delete schema.indexes[indexName];
delete myConn.indexMap[indexName];
for (const storeName in schema.objectStores) {
const store = schema.objectStores[storeName];
store.indexes = store.indexes.filter(x => {
return x !== indexName;
});
}
}
deleteObjectStore(btx: DatabaseTransaction, name: string): void {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: deleteObjectStore(${name})`);
}
2019-06-15 22:44:54 +02:00
const myConn = this.connections[btx.transactionCookie];
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.VersionChange) {
throw Error("only allowed in versionchange transaction");
}
const schema = myConn.modifiedSchema;
if (!schema) {
throw Error();
}
const objectStoreProperties = schema.objectStores[name];
if (!objectStoreProperties) {
throw Error("object store not found");
}
const objectStore = myConn.objectStoreMap[name];
if (!objectStore) {
throw Error("object store not found in map");
}
const indexNames = objectStoreProperties.indexes;
for (const indexName of indexNames) {
this.deleteIndex(btx, indexName);
}
objectStore.deleted = true;
delete myConn.objectStoreMap[name];
delete schema.objectStores[name];
}
renameObjectStore(
btx: DatabaseTransaction,
oldName: string,
newName: string,
): void {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: renameObjectStore(?, ${oldName}, ${newName})`);
}
2019-06-15 22:44:54 +02:00
const myConn = this.connections[btx.transactionCookie];
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.VersionChange) {
throw Error("only allowed in versionchange transaction");
}
const schema = myConn.modifiedSchema;
if (!schema) {
throw Error();
}
if (!schema.objectStores[oldName]) {
throw Error("object store not found");
}
if (schema.objectStores[newName]) {
throw Error("new object store already exists");
}
const objectStore = myConn.objectStoreMap[oldName];
if (!objectStore) {
throw Error("object store not found in map");
}
objectStore.modifiedName = newName;
schema.objectStores[newName] = schema.objectStores[oldName];
delete schema.objectStores[oldName];
delete myConn.objectStoreMap[oldName];
myConn.objectStoreMap[newName] = objectStore;
}
createObjectStore(
btx: DatabaseTransaction,
name: string,
keyPath: string | string[] | null,
autoIncrement: boolean,
): void {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(
`TRACING: createObjectStore(${btx.transactionCookie}, ${name})`,
);
}
const myConn = this.connectionsByTransaction[btx.transactionCookie];
2019-06-15 22:44:54 +02:00
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.VersionChange) {
throw Error("only allowed in versionchange transaction");
}
const newObjectStore: ObjectStore = {
deleted: false,
modifiedName: undefined,
originalName: name,
modifiedData: undefined,
originalData: new BTree([], compareKeys),
modifiedKeyGenerator: undefined,
originalKeyGenerator: 1,
};
const schema = myConn.modifiedSchema;
if (!schema) {
throw Error("no schema for versionchange tx");
}
schema.objectStores[name] = {
autoIncrement,
keyPath,
indexes: [],
};
myConn.objectStoreMap[name] = newObjectStore;
db.modifiedObjectStores[name] = newObjectStore;
}
createIndex(
btx: DatabaseTransaction,
indexName: string,
objectStoreName: string,
keyPath: import("./util/types").KeyPath,
multiEntry: boolean,
unique: boolean,
): void {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: createIndex(${indexName})`);
}
const myConn = this.connectionsByTransaction[btx.transactionCookie];
2019-06-15 22:44:54 +02:00
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.VersionChange) {
throw Error("only allowed in versionchange transaction");
}
const indexProperties: IndexProperties = {
keyPath,
multiEntry,
unique,
};
const newIndex: Index = {
deleted: false,
modifiedData: undefined,
modifiedName: undefined,
originalData: new BTree([], compareKeys),
originalName: indexName,
};
myConn.indexMap[indexName] = newIndex;
db.modifiedIndexes[indexName] = newIndex;
const schema = myConn.modifiedSchema;
if (!schema) {
throw Error("no schema in versionchange tx");
}
const objectStoreProperties = schema.objectStores[objectStoreName];
if (!objectStoreProperties) {
throw Error("object store not found");
}
objectStoreProperties.indexes.push(indexName);
schema.indexes[indexName] = indexProperties;
2019-06-25 13:44:03 +02:00
const objectStore = myConn.objectStoreMap[objectStoreName];
if (!objectStore) {
throw Error("object store does not exist");
}
const storeData = objectStore.modifiedData || objectStore.originalData;
storeData.forEach((v, k) => {
this.insertIntoIndex(newIndex, k, v.value, indexProperties);
});
2019-06-15 22:44:54 +02:00
}
async deleteRecord(
btx: DatabaseTransaction,
objectStoreName: string,
range: BridgeIDBKeyRange,
2019-06-15 22:44:54 +02:00
): Promise<void> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: deleteRecord from store ${objectStoreName}`);
2019-06-21 19:18:36 +02:00
}
const myConn = this.connectionsByTransaction[btx.transactionCookie];
2019-06-15 22:44:54 +02:00
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.Write) {
throw Error("only allowed in write transaction");
}
if (typeof range !== "object") {
throw Error("deleteRecord got invalid range (must be object)");
}
if (!("lowerOpen" in range)) {
2019-08-16 19:05:48 +02:00
throw Error(
"deleteRecord got invalid range (sanity check failed, 'lowerOpen' missing)",
);
}
const schema = myConn.modifiedSchema
? myConn.modifiedSchema
: db.committedSchema;
const objectStore = myConn.objectStoreMap[objectStoreName];
if (!objectStore.modifiedData) {
objectStore.modifiedData = objectStore.originalData;
}
let modifiedData = objectStore.modifiedData;
let currKey: Key | undefined;
if (range.lower === undefined || range.lower === null) {
currKey = modifiedData.minKey();
} else {
currKey = range.lower;
// We have a range with an lowerOpen lower bound, so don't start
// deleting the upper bound. Instead start with the next higher key.
if (range.lowerOpen && currKey !== undefined) {
2019-08-16 19:05:48 +02:00
currKey = modifiedData.nextHigherKey(currKey);
}
}
// invariant: (currKey is undefined) or (currKey is a valid key)
while (true) {
if (currKey === undefined) {
// nothing more to delete!
break;
}
if (range.upper !== null && range.upper !== undefined) {
if (range.upperOpen && compareKeys(currKey, range.upper) === 0) {
// We have a range that's upperOpen, so stop before we delete the upper bound.
break;
}
2019-08-16 19:05:48 +02:00
if (!range.upperOpen && compareKeys(currKey, range.upper) > 0) {
// The upper range is inclusive, only stop if we're after the upper range.
break;
}
}
const storeEntry = modifiedData.get(currKey);
if (!storeEntry) {
throw Error("assertion failed");
}
for (const indexName of schema.objectStores[objectStoreName].indexes) {
const index = myConn.indexMap[indexName];
if (!index) {
throw Error("index referenced by object store does not exist");
}
const indexProperties = schema.indexes[indexName];
2019-08-16 19:05:48 +02:00
this.deleteFromIndex(
index,
storeEntry.primaryKey,
storeEntry.value,
indexProperties,
);
}
modifiedData = modifiedData.without(currKey);
currKey = modifiedData.nextHigherKey(currKey);
}
objectStore.modifiedData = modifiedData;
}
private deleteFromIndex(
index: Index,
primaryKey: Key,
value: Value,
indexProperties: IndexProperties,
): void {
if (this.enableTracing) {
console.log(
`deleteFromIndex(${index.modifiedName || index.originalName})`,
);
}
if (value === undefined || value === null) {
throw Error("cannot delete null/undefined value from index");
}
let indexData = index.modifiedData || index.originalData;
const indexKeys = getIndexKeys(
value,
indexProperties.keyPath,
indexProperties.multiEntry,
);
for (const indexKey of indexKeys) {
const existingRecord = indexData.get(indexKey);
if (!existingRecord) {
throw Error("db inconsistent: expected index entry missing");
}
2019-08-16 19:05:48 +02:00
const newPrimaryKeys = existingRecord.primaryKeys.filter(
x => compareKeys(x, primaryKey) !== 0,
);
if (newPrimaryKeys.length === 0) {
index.originalData = indexData.without(indexKey);
} else {
const newIndexRecord = {
indexKey,
primaryKeys: newPrimaryKeys,
2019-08-16 19:05:48 +02:00
};
index.modifiedData = indexData.with(indexKey, newIndexRecord, true);
}
}
2019-06-15 22:44:54 +02:00
}
async getRecords(
btx: DatabaseTransaction,
2019-06-21 19:18:36 +02:00
req: RecordGetRequest,
): Promise<RecordGetResponse> {
if (this.enableTracing) {
console.log(`TRACING: getRecords`);
2019-06-23 22:16:03 +02:00
console.log("query", req);
2019-06-21 19:18:36 +02:00
}
const myConn = this.connectionsByTransaction[btx.transactionCookie];
2019-06-15 22:44:54 +02:00
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
2019-06-21 19:18:36 +02:00
if (db.txLevel < TransactionLevel.Read) {
2019-06-15 22:44:54 +02:00
throw Error("only allowed while running a transaction");
}
2019-06-21 19:18:36 +02:00
const objectStore = myConn.objectStoreMap[req.objectStoreName];
if (!objectStore) {
throw Error("object store not found");
}
let range;
if (req.range == null || req.range === undefined) {
2019-06-23 22:16:03 +02:00
range = new BridgeIDBKeyRange(undefined, undefined, true, true);
2019-06-21 19:18:36 +02:00
} else {
range = req.range;
}
if (typeof range !== "object") {
throw Error(
"getRecords was given an invalid range (sanity check failed, not an object)",
);
}
if (!("lowerOpen" in range)) {
throw Error(
"getRecords was given an invalid range (sanity check failed, lowerOpen missing)",
);
}
2019-06-21 19:18:36 +02:00
let numResults = 0;
let indexKeys: Key[] = [];
2019-06-23 22:16:03 +02:00
let primaryKeys: Key[] = [];
let values: Value[] = [];
2019-06-21 19:18:36 +02:00
const forward: boolean =
req.direction === "next" || req.direction === "nextunique";
const unique: boolean =
req.direction === "prevunique" || req.direction === "nextunique";
const storeData = objectStore.modifiedData || objectStore.originalData;
const haveIndex = req.indexName !== undefined;
if (haveIndex) {
const index = myConn.indexMap[req.indexName!];
const indexData = index.modifiedData || index.originalData;
let indexPos = req.lastIndexPosition;
if (indexPos === undefined) {
// First time we iterate! So start at the beginning (lower/upper)
// of our allowed range.
indexPos = forward ? range.lower : range.upper;
}
let primaryPos = req.lastObjectStorePosition;
// We might have to advance the index key further!
if (req.advanceIndexKey !== undefined) {
const compareResult = compareKeys(req.advanceIndexKey, indexPos);
if ((forward && compareResult > 0) || (!forward && compareResult > 0)) {
indexPos = req.advanceIndexKey;
} else if (compareResult == 0 && req.advancePrimaryKey !== undefined) {
// index keys are the same, so advance the primary key
if (primaryPos === undefined) {
primaryPos = req.advancePrimaryKey;
} else {
const primCompareResult = compareKeys(
req.advancePrimaryKey,
primaryPos,
);
if (
(forward && primCompareResult > 0) ||
(!forward && primCompareResult < 0)
) {
primaryPos = req.advancePrimaryKey;
}
}
}
}
2019-06-25 12:49:43 +02:00
if (indexPos === undefined || indexPos === null) {
indexPos = forward ? indexData.minKey() : indexData.maxKey();
}
let indexEntry: IndexRecord | undefined;
2019-06-21 19:18:36 +02:00
indexEntry = indexData.get(indexPos);
if (!indexEntry) {
const res = indexData.nextHigherPair(indexPos);
if (res) {
indexEntry = res[1];
}
}
let primkeySubPos = 0;
// Sort out the case where the index key is the same, so we have
// to get the prev/next primary key
if (
2019-06-25 12:49:43 +02:00
indexEntry !== undefined &&
2019-06-21 19:18:36 +02:00
req.lastIndexPosition !== undefined &&
compareKeys(indexEntry.indexKey, req.lastIndexPosition) === 0
) {
let pos = forward ? 0 : indexEntry.primaryKeys.length - 1;
this.enableTracing &&
console.log("number of primary keys", indexEntry.primaryKeys.length);
this.enableTracing && console.log("start pos is", pos);
2019-06-21 19:18:36 +02:00
// Advance past the lastObjectStorePosition
2019-06-25 12:49:43 +02:00
do {
2019-06-21 19:18:36 +02:00
const cmpResult = compareKeys(
req.lastObjectStorePosition,
indexEntry.primaryKeys[pos],
);
this.enableTracing && console.log("cmp result is", cmpResult);
2019-06-21 19:18:36 +02:00
if ((forward && cmpResult < 0) || (!forward && cmpResult > 0)) {
break;
}
pos += forward ? 1 : -1;
this.enableTracing && console.log("now pos is", pos);
2019-06-25 12:49:43 +02:00
} while (pos >= 0 && pos < indexEntry.primaryKeys.length);
2019-06-21 19:18:36 +02:00
// Make sure we're at least at advancedPrimaryPos
while (
primaryPos !== undefined &&
pos >= 0 &&
pos < indexEntry.primaryKeys.length
) {
const cmpResult = compareKeys(
primaryPos,
indexEntry.primaryKeys[pos],
);
if ((forward && cmpResult <= 0) || (!forward && cmpResult >= 0)) {
break;
}
pos += forward ? 1 : -1;
}
primkeySubPos = pos;
2019-06-25 12:49:43 +02:00
} else if (indexEntry !== undefined) {
2019-06-21 19:18:36 +02:00
primkeySubPos = forward ? 0 : indexEntry.primaryKeys.length - 1;
}
if (this.enableTracing) {
console.log("subPos=", primkeySubPos);
console.log("indexPos=", indexPos);
}
2019-06-25 12:49:43 +02:00
2019-06-21 19:18:36 +02:00
while (1) {
if (req.limit != 0 && numResults == req.limit) {
break;
}
if (indexPos === undefined) {
break;
}
if (!range.includes(indexPos)) {
break;
}
2019-06-25 12:49:43 +02:00
if (indexEntry === undefined) {
break;
}
2019-06-21 19:18:36 +02:00
if (
primkeySubPos < 0 ||
primkeySubPos >= indexEntry.primaryKeys.length
) {
2019-06-25 14:31:48 +02:00
const res = forward
? indexData.nextHigherPair(indexPos)
: indexData.nextLowerPair(indexPos);
2019-06-21 19:18:36 +02:00
if (res) {
indexPos = res[1].indexKey;
2019-06-25 12:49:43 +02:00
indexEntry = res[1];
2019-06-25 14:31:48 +02:00
primkeySubPos = forward ? 0 : indexEntry.primaryKeys.length - 1;
2019-06-21 19:18:36 +02:00
} else {
break;
}
}
2019-06-25 13:18:09 +02:00
// Skip repeated index keys if unique results are requested.
let skip = false;
if (unique) {
if (
indexKeys.length > 0 &&
compareKeys(
indexEntry.indexKey,
indexKeys[indexKeys.length - 1],
) === 0
) {
skip = true;
}
if (
req.lastIndexPosition !== undefined &&
compareKeys(indexPos, req.lastIndexPosition) === 0
) {
skip = true;
}
2019-06-23 22:16:03 +02:00
}
2019-06-25 13:18:09 +02:00
if (!skip) {
if (this.enableTracing) {
console.log(`not skipping!, subPos=${primkeySubPos}`);
}
2019-06-25 13:18:09 +02:00
indexKeys.push(indexEntry.indexKey);
primaryKeys.push(indexEntry.primaryKeys[primkeySubPos]);
numResults++;
2019-06-25 14:31:48 +02:00
} else {
if (this.enableTracing) {
console.log("skipping!");
}
2019-06-25 13:18:09 +02:00
}
primkeySubPos += forward ? 1 : -1;
2019-06-21 19:18:36 +02:00
}
// Now we can collect the values based on the primary keys,
// if requested.
if (req.resultLevel === ResultLevel.Full) {
for (let i = 0; i < numResults; i++) {
const result = storeData.get(primaryKeys[i]);
if (!result) {
throw Error("invariant violated");
}
values.push(structuredClone(result.value));
2019-06-21 19:18:36 +02:00
}
}
} else {
// only based on object store, no index involved, phew!
let storePos = req.lastObjectStorePosition;
if (storePos === undefined) {
storePos = forward ? range.lower : range.upper;
}
if (req.advanceIndexKey !== undefined) {
throw Error("unsupported request");
}
storePos = furthestKey(forward, req.advancePrimaryKey, storePos);
2019-06-23 22:16:03 +02:00
if (storePos !== null && storePos !== undefined) {
// Advance store position if we are either still at the last returned
// store key, or if we are currently not on a key.
const storeEntry = storeData.get(storePos);
if (this.enableTracing) {
console.log("store entry:", storeEntry);
}
2019-06-23 22:16:03 +02:00
if (
!storeEntry ||
(req.lastObjectStorePosition !== undefined &&
2019-06-25 12:49:43 +02:00
compareKeys(req.lastObjectStorePosition, storePos) === 0)
2019-06-23 22:16:03 +02:00
) {
storePos = storeData.nextHigherKey(storePos);
}
} else {
storePos = forward ? storeData.minKey() : storeData.maxKey();
if (this.enableTracing) {
console.log("setting starting store pos to", storePos);
}
2019-06-21 19:18:36 +02:00
}
2019-06-23 22:16:03 +02:00
while (1) {
if (req.limit != 0 && numResults == req.limit) {
break;
}
if (storePos === null || storePos === undefined) {
break;
}
if (!range.includes(storePos)) {
break;
}
2019-06-21 19:18:36 +02:00
2019-06-23 22:16:03 +02:00
const res = storeData.get(storePos);
2019-06-21 19:18:36 +02:00
2019-06-23 22:16:03 +02:00
if (res === undefined) {
break;
}
2019-06-21 19:18:36 +02:00
2019-06-23 22:16:03 +02:00
if (req.resultLevel >= ResultLevel.OnlyKeys) {
primaryKeys.push(structuredClone(storePos));
}
2019-06-21 19:18:36 +02:00
2019-06-23 22:16:03 +02:00
if (req.resultLevel >= ResultLevel.Full) {
values.push(structuredClone(res.value));
2019-06-21 19:18:36 +02:00
}
2019-06-23 22:16:03 +02:00
numResults++;
storePos = nextStoreKey(forward, storeData, storePos);
}
2019-06-21 19:18:36 +02:00
}
if (this.enableTracing) {
2019-06-23 22:16:03 +02:00
console.log(`TRACING: getRecords got ${numResults} results`);
2019-06-21 19:18:36 +02:00
}
return {
count: numResults,
indexKeys:
req.resultLevel >= ResultLevel.OnlyKeys && haveIndex
? indexKeys
: undefined,
primaryKeys:
req.resultLevel >= ResultLevel.OnlyKeys ? primaryKeys : undefined,
values: req.resultLevel >= ResultLevel.Full ? values : undefined,
};
2019-06-15 22:44:54 +02:00
}
async storeRecord(
btx: DatabaseTransaction,
storeReq: RecordStoreRequest,
): Promise<RecordStoreResponse> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: storeRecord`);
}
const myConn = this.connectionsByTransaction[btx.transactionCookie];
2019-06-15 22:44:54 +02:00
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.Write) {
throw Error("only allowed while running a transaction");
}
const schema = myConn.modifiedSchema
? myConn.modifiedSchema
: db.committedSchema;
const objectStore = myConn.objectStoreMap[storeReq.objectStoreName];
if (!objectStore.modifiedData) {
objectStore.modifiedData = objectStore.originalData;
}
const modifiedData = objectStore.modifiedData;
let key;
let value;
if (storeReq.storeLevel === StoreLevel.UpdateExisting) {
if (storeReq.key === null || storeReq.key === undefined) {
throw Error("invalid update request (key not given)");
}
if (!objectStore.modifiedData.has(storeReq.key)) {
throw Error("invalid update request (record does not exist)");
}
key = storeReq.key;
value = storeReq.value;
} else {
const storeKeyResult: StoreKeyResult = makeStoreKeyValue(
storeReq.value,
storeReq.key,
objectStore.modifiedKeyGenerator || objectStore.originalKeyGenerator,
schema.objectStores[storeReq.objectStoreName].autoIncrement,
schema.objectStores[storeReq.objectStoreName].keyPath,
);
key = storeKeyResult.key;
value = storeKeyResult.value;
objectStore.modifiedKeyGenerator = storeKeyResult.updatedKeyGenerator;
const hasKey = modifiedData.has(key);
if (hasKey && storeReq.storeLevel !== StoreLevel.AllowOverwrite) {
throw Error("refusing to overwrite");
}
2019-06-15 22:44:54 +02:00
}
const objectStoreRecord: ObjectStoreRecord = {
primaryKey: structuredClone(key),
value: structuredClone(value),
};
objectStore.modifiedData = modifiedData.with(key, objectStoreRecord, true);
2019-06-15 22:44:54 +02:00
for (const indexName of schema.objectStores[storeReq.objectStoreName]
.indexes) {
const index = myConn.indexMap[indexName];
if (!index) {
throw Error("index referenced by object store does not exist");
}
const indexProperties = schema.indexes[indexName];
2019-06-21 19:18:36 +02:00
this.insertIntoIndex(index, key, value, indexProperties);
}
return { key };
2019-06-21 19:18:36 +02:00
}
2019-06-23 22:16:03 +02:00
private insertIntoIndex(
2019-06-21 19:18:36 +02:00
index: Index,
primaryKey: Key,
value: Value,
indexProperties: IndexProperties,
): void {
if (this.enableTracing) {
console.log(
`insertIntoIndex(${index.modifiedName || index.originalName})`,
);
}
let indexData = index.modifiedData || index.originalData;
const indexKeys = getIndexKeys(
value,
indexProperties.keyPath,
indexProperties.multiEntry,
);
for (const indexKey of indexKeys) {
const existingRecord = indexData.get(indexKey);
if (existingRecord) {
if (indexProperties.unique) {
throw new ConstraintError();
} else {
const newIndexRecord = {
indexKey: indexKey,
2019-06-23 22:16:03 +02:00
primaryKeys: [primaryKey]
.concat(existingRecord.primaryKeys)
.sort(compareKeys),
2019-06-21 19:18:36 +02:00
};
index.modifiedData = indexData.with(indexKey, newIndexRecord, true);
}
} else {
const newIndexRecord: IndexRecord = {
indexKey: indexKey,
primaryKeys: [primaryKey],
};
index.modifiedData = indexData.with(indexKey, newIndexRecord, true);
}
2019-06-15 22:44:54 +02:00
}
}
async rollback(btx: DatabaseTransaction): Promise<void> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: rollback`);
}
const myConn = this.connectionsByTransaction[btx.transactionCookie];
2019-06-15 22:44:54 +02:00
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
if (db.txLevel < TransactionLevel.Read) {
throw Error("only allowed while running a transaction");
}
db.modifiedIndexes = {};
db.modifiedObjectStores = {};
db.txLevel = TransactionLevel.Connected;
myConn.modifiedSchema = structuredClone(db.committedSchema);
myConn.indexMap = Object.assign({}, db.committedIndexes);
myConn.objectStoreMap = Object.assign({}, db.committedObjectStores);
for (const indexName in db.committedIndexes) {
const index = db.committedIndexes[indexName];
index.deleted = false;
index.modifiedData = undefined;
index.modifiedName = undefined;
}
for (const objectStoreName in db.committedObjectStores) {
const objectStore = db.committedObjectStores[objectStoreName];
objectStore.deleted = false;
objectStore.modifiedData = undefined;
objectStore.modifiedName = undefined;
objectStore.modifiedKeyGenerator = undefined;
}
2019-06-21 19:18:36 +02:00
delete this.connectionsByTransaction[btx.transactionCookie];
this.transactionDoneCond.trigger();
2019-06-15 22:44:54 +02:00
}
async commit(btx: DatabaseTransaction): Promise<void> {
2019-06-21 19:18:36 +02:00
if (this.enableTracing) {
console.log(`TRACING: commit`);
}
const myConn = this.connectionsByTransaction[btx.transactionCookie];
2019-06-15 22:44:54 +02:00
if (!myConn) {
throw Error("unknown connection");
}
const db = this.databases[myConn.dbName];
if (!db) {
throw Error("db not found");
}
const txLevel = db.txLevel;
if (txLevel < TransactionLevel.Read) {
2019-06-15 22:44:54 +02:00
throw Error("only allowed while running a transaction");
}
2019-06-21 19:18:36 +02:00
db.committedSchema = myConn.modifiedSchema || db.committedSchema;
db.txLevel = TransactionLevel.Connected;
db.committedIndexes = {};
db.committedObjectStores = {};
db.modifiedIndexes = {};
db.committedObjectStores = {};
for (const indexName in myConn.indexMap) {
const index = myConn.indexMap[indexName];
index.deleted = false;
index.originalData = index.modifiedData || index.originalData;
index.originalName = index.modifiedName || index.originalName;
db.committedIndexes[indexName] = index;
}
for (const objectStoreName in myConn.objectStoreMap) {
const objectStore = myConn.objectStoreMap[objectStoreName];
objectStore.deleted = false;
objectStore.originalData =
objectStore.modifiedData || objectStore.originalData;
objectStore.originalName =
objectStore.modifiedName || objectStore.originalName;
if (objectStore.modifiedKeyGenerator !== undefined) {
objectStore.originalKeyGenerator = objectStore.modifiedKeyGenerator;
}
db.committedObjectStores[objectStoreName] = objectStore;
}
myConn.indexMap = Object.assign({}, db.committedIndexes);
myConn.objectStoreMap = Object.assign({}, db.committedObjectStores);
delete this.connectionsByTransaction[btx.transactionCookie];
this.transactionDoneCond.trigger();
2019-08-16 19:05:48 +02:00
if (this.afterCommitCallback && txLevel >= TransactionLevel.Write) {
2019-08-16 19:05:48 +02:00
await this.afterCommitCallback();
}
2019-06-15 22:44:54 +02:00
}
}
export default MemoryBackend;