/*
 Copyright 2023 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

/**
 * Imports.
 */
import { AsyncCondition } from "./backend-common.js";
import {
  Backend,
  ConnectResult,
  DatabaseConnection,
  DatabaseTransaction,
  IndexGetQuery,
  IndexMeta,
  ObjectStoreGetQuery,
  ObjectStoreMeta,
  RecordGetResponse,
  RecordStoreRequest,
  RecordStoreResponse,
  ResultLevel,
  StoreLevel,
} from "./backend-interface.js";
import { BridgeIDBDatabaseInfo, BridgeIDBKeyRange } from "./bridge-idb.js";
import {
  IDBKeyPath,
  IDBKeyRange,
  IDBTransactionMode,
  IDBValidKey,
} from "./idbtypes.js";
import {
  AccessStats,
  structuredEncapsulate,
  structuredRevive,
} from "./index.js";
import {
  Sqlite3Database,
  Sqlite3Interface,
  Sqlite3Statement,
} from "./sqlite3-interface.js";
import { ConstraintError, DataError } from "./util/errors.js";
import { getIndexKeys } from "./util/getIndexKeys.js";
import { deserializeKey, serializeKey } from "./util/key-storage.js";
import { makeStoreKeyValue } from "./util/makeStoreKeyValue.js";

function assertDbInvariant(b: boolean): asserts b {
  if (!b) {
    throw Error("internal invariant failed");
  }
}

const SqliteError = {
  constraintPrimarykey: "SQLITE_CONSTRAINT_PRIMARYKEY",
} as const;

export type SqliteRowid = number | bigint;

enum TransactionLevel {
  None = 0,
  Read = 1,
  Write = 2,
  VersionChange = 3,
}

interface ConnectionInfo {
  // Database that the connection has
  // connected to.
  databaseName: string;

  storeMap: Map<string, MyStoreMeta>;
  storeList: MyStoreMeta[];
}

interface TransactionInfo {
  connectionCookie: string;
}

interface MyIndexMeta {
  indexId: SqliteRowid | undefined;
  keyPath: IDBKeyPath | IDBKeyPath[];
  multiEntry: boolean;
  unique: boolean;

  currentName: string | undefined;
  nameDirty: boolean;
}

interface MyStoreMeta {
  /**
   * Internal ID of the object store.
   * Used for fast retrieval, since it's the
   * primary key / rowid of the sqlite table.
   */
  objectStoreId: SqliteRowid | undefined;

  keyPath: string | string[] | null;

  autoIncrement: boolean;

  indexList: MyIndexMeta[];
  indexMap: Map<string, MyIndexMeta>;

  currentName: string | undefined;

  nameDirty: boolean;
}

interface IndexIterPos {
  objectPos: Uint8Array;
  indexPos: Uint8Array;
}

export function serializeKeyPath(
  keyPath: string | string[] | null,
): string | null {
  if (Array.isArray(keyPath)) {
    return "," + keyPath.join(",");
  }
  return keyPath;
}

export function deserializeKeyPath(
  dbKeyPath: string | null,
): string | string[] | null {
  if (dbKeyPath == null) {
    return null;
  }
  if (dbKeyPath[0] === ",") {
    const elems = dbKeyPath.split(",");
    elems.splice(0, 1);
    return elems;
  } else {
    return dbKeyPath;
  }
}

interface Boundary {
  key: Uint8Array;
  inclusive: boolean;
}

function getRangeEndBoundary(
  forward: boolean,
  range: IDBKeyRange | undefined | null,
): Boundary | undefined {
  let endRangeKey: Uint8Array | undefined = undefined;
  let endRangeInclusive: boolean = false;
  if (range) {
    if (forward && range.upper != null) {
      endRangeKey = serializeKey(range.upper);
      endRangeInclusive = !range.upperOpen;
    } else if (!forward && range.lower != null) {
      endRangeKey = serializeKey(range.lower);
      endRangeInclusive = !range.lowerOpen;
    }
  }
  if (endRangeKey) {
    return {
      inclusive: endRangeInclusive,
      key: endRangeKey,
    };
  }
  return undefined;
}

function isOutsideBoundary(
  forward: boolean,
  endRange: Boundary,
  currentKey: Uint8Array,
): boolean {
  const cmp = compareSerializedKeys(currentKey, endRange.key);
  if (forward && endRange.inclusive && cmp > 0) {
    return true;
  } else if (forward && !endRange.inclusive && cmp >= 0) {
    return true;
  } else if (!forward && endRange.inclusive && cmp < 0) {
    return true;
  } else if (!forward && !endRange.inclusive && cmp <= 0) {
    return true;
  }
  return false;
}

function compareSerializedKeys(k1: Uint8Array, k2: Uint8Array): number {
  // FIXME: Simplify!
  let i = 0;
  while (1) {
    let x1 = i >= k1.length ? -1 : k1[i];
    let x2 = i >= k2.length ? -1 : k2[i];
    if (x1 < x2) {
      return -1;
    }
    if (x1 > x2) {
      return 1;
    }
    if (x1 < 0 && x2 < 0) {
      return 0;
    }
    i++;
  }
  throw Error("not reached");
}

export function expectDbNumber(
  resultRow: unknown,
  name: string,
): number | bigint {
  assertDbInvariant(typeof resultRow === "object" && resultRow != null);
  const res = (resultRow as any)[name];
  if (typeof res !== "number") {
    throw Error("unexpected type from database (expected number)");
  }
  return res;
}

export function expectDbString(resultRow: unknown, name: string): string {
  assertDbInvariant(typeof resultRow === "object" && resultRow != null);
  const res = (resultRow as any)[name];
  if (typeof res !== "string") {
    throw Error("unexpected type from database (expected string)");
  }
  return res;
}

export function expectDbStringOrNull(
  resultRow: unknown,
  name: string,
): string | null {
  assertDbInvariant(typeof resultRow === "object" && resultRow != null);
  const res = (resultRow as any)[name];
  if (res == null) {
    return null;
  }
  if (typeof res !== "string") {
    throw Error("unexpected type from database (expected string or null)");
  }
  return res;
}

export class SqliteBackend implements Backend {
  private connectionIdCounter = 1;
  private transactionIdCounter = 1;

  trackStats = false;

  accessStats: AccessStats = {
    primitiveStatements: 0, // Counted by the sqlite impl
    readTransactions: 0,
    writeTransactions: 0,
    readsPerStore: {},
    readsPerIndex: {},
    readItemsPerIndex: {},
    readItemsPerStore: {},
    writesPerStore: {},
  };

  /**
   * Condition that is triggered whenever a transaction finishes.
   */
  private transactionDoneCond: AsyncCondition = new AsyncCondition();

  private txLevel: TransactionLevel = TransactionLevel.None;

  private connectionMap: Map<string, ConnectionInfo> = new Map();

  private transactionMap: Map<string, TransactionInfo> = new Map();

  private sqlPrepCache: Map<string, Sqlite3Statement> = new Map();

  enableTracing: boolean = true;

  constructor(
    public sqliteImpl: Sqlite3Interface,
    public db: Sqlite3Database,
  ) {}

  private async _prep(sql: string): Promise<Sqlite3Statement> {
    const stmt = this.sqlPrepCache.get(sql);
    if (stmt) {
      return stmt;
    }
    const newStmt = await this.db.prepare(sql);
    this.sqlPrepCache.set(sql, newStmt);
    return newStmt;
  }

  async getIndexRecords(
    btx: DatabaseTransaction,
    req: IndexGetQuery,
  ): Promise<RecordGetResponse> {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    if (this.txLevel < TransactionLevel.Read) {
      throw Error("only allowed in read transaction");
    }
    const scopeInfo = connInfo.storeMap.get(req.objectStoreName);
    if (!scopeInfo) {
      throw Error("object store not in scope");
    }
    const indexInfo = scopeInfo.indexMap.get(req.indexName);
    if (!indexInfo) {
      throw Error("index not found");
    }
    if (req.advancePrimaryKey != null) {
      if (req.advanceIndexKey == null) {
        throw Error(
          "invalid request (advancePrimaryKey without advanceIndexKey)",
        );
      }
    }

    const objectStoreId = await this._provideObjectStore(connInfo, scopeInfo);

    if (this.enableTracing) {
      console.log(
        `querying index os=${req.objectStoreName}, idx=${req.indexName}, direction=${req.direction}`,
      );
    }

    const forward: boolean =
      req.direction === "next" || req.direction === "nextunique";

    const queryUnique =
      req.direction === "nextunique" || req.direction === "prevunique";

    const indexId = await this._provideIndex(connInfo, scopeInfo, indexInfo);
    const indexUnique = indexInfo.unique;

    let numResults = 0;
    const encPrimaryKeys: Uint8Array[] = [];
    const encIndexKeys: Uint8Array[] = [];
    const indexKeys: IDBValidKey[] = [];
    const primaryKeys: IDBValidKey[] = [];
    const values: unknown[] = [];

    const endRange = getRangeEndBoundary(forward, req.range);

    const backendThis = this;

    async function packResult() {
      if (req.resultLevel > ResultLevel.OnlyCount) {
        for (let i = 0; i < encPrimaryKeys.length; i++) {
          primaryKeys.push(deserializeKey(encPrimaryKeys[i]));
        }
        for (let i = 0; i < encIndexKeys.length; i++) {
          indexKeys.push(deserializeKey(encIndexKeys[i]));
        }
        if (req.resultLevel === ResultLevel.Full) {
          for (let i = 0; i < encPrimaryKeys.length; i++) {
            const val = await backendThis._getObjectValue(
              objectStoreId,
              encPrimaryKeys[i],
            );
            if (!val) {
              throw Error("invariant failed: value not found");
            }
            values.push(structuredRevive(JSON.parse(val)));
          }
        }
      }

      if (backendThis.enableTracing) {
        console.log(`index query returned ${numResults} results`);
        console.log(`result prim keys:`, primaryKeys);
        console.log(`result index keys:`, indexKeys);
      }

      if (backendThis.trackStats) {
        const k = `${req.objectStoreName}.${req.indexName}`;
        backendThis.accessStats.readsPerIndex[k] =
          (backendThis.accessStats.readsPerIndex[k] ?? 0) + 1;
        backendThis.accessStats.readItemsPerIndex[k] =
          (backendThis.accessStats.readItemsPerIndex[k] ?? 0) + numResults;
      }

      return {
        count: numResults,
        indexKeys: indexKeys,
        primaryKeys:
          req.resultLevel >= ResultLevel.OnlyKeys ? primaryKeys : undefined,
        values: req.resultLevel >= ResultLevel.Full ? values : undefined,
      };
    }

    let currentPos = await this._startIndex({
      indexId,
      indexUnique,
      queryUnique,
      forward,
    });

    if (!currentPos) {
      return packResult();
    }

    if (this.enableTracing && currentPos) {
      console.log(`starting iteration at:`);
      console.log(`indexKey:`, deserializeKey(currentPos.indexPos));
      console.log(`objectKey:`, deserializeKey(currentPos.objectPos));
    }

    if (req.advanceIndexKey) {
      const advanceIndexKey = serializeKey(req.advanceIndexKey);
      const advancePrimaryKey = req.advancePrimaryKey
        ? serializeKey(req.advancePrimaryKey)
        : undefined;
      currentPos = await this._continueIndex({
        indexId,
        indexUnique,
        queryUnique,
        inclusive: true,
        currentPos,
        forward,
        targetIndexKey: advanceIndexKey,
        targetObjectKey: advancePrimaryKey,
      });
      if (!currentPos) {
        return packResult();
      }
    }

    if (req.lastIndexPosition) {
      if (this.enableTracing) {
        console.log("index query: seeking past last index position");
        console.log("lastObjectPosition", req.lastObjectStorePosition);
        console.log("lastIndexPosition", req.lastIndexPosition);
      }
      const lastIndexPosition = serializeKey(req.lastIndexPosition);
      const lastObjectPosition = req.lastObjectStorePosition
        ? serializeKey(req.lastObjectStorePosition)
        : undefined;
      currentPos = await this._continueIndex({
        indexId,
        indexUnique,
        queryUnique,
        inclusive: false,
        currentPos,
        forward,
        targetIndexKey: lastIndexPosition,
        targetObjectKey: lastObjectPosition,
      });
      if (!currentPos) {
        return packResult();
      }
    }

    if (this.enableTracing && currentPos) {
      console.log(
        "before range, current index pos",
        deserializeKey(currentPos.indexPos),
      );
      console.log(
        "... current object pos",
        deserializeKey(currentPos.objectPos),
      );
    }

    if (req.range != null) {
      const targetKeyObj = forward ? req.range.lower : req.range.upper;
      if (targetKeyObj != null) {
        const targetKey = serializeKey(targetKeyObj);
        const inclusive = forward ? !req.range.lowerOpen : !req.range.upperOpen;
        currentPos = await this._continueIndex({
          indexId,
          indexUnique,
          queryUnique,
          inclusive,
          currentPos,
          forward,
          targetIndexKey: targetKey,
        });
      }
      if (!currentPos) {
        return packResult();
      }
    }

    if (this.enableTracing && currentPos) {
      console.log(
        "after range, current pos",
        deserializeKey(currentPos.indexPos),
      );
      console.log(
        "after range, current obj pos",
        deserializeKey(currentPos.objectPos),
      );
    }

    while (1) {
      if (req.limit != 0 && numResults == req.limit) {
        break;
      }
      if (currentPos == null) {
        break;
      }
      if (
        endRange &&
        isOutsideBoundary(forward, endRange, currentPos.indexPos)
      ) {
        break;
      }

      numResults++;

      if (req.resultLevel > ResultLevel.OnlyCount) {
        encPrimaryKeys.push(currentPos.objectPos);
        encIndexKeys.push(currentPos.indexPos);
      }

      currentPos = await backendThis._continueIndex({
        indexId,
        indexUnique,
        forward,
        inclusive: false,
        currentPos: undefined,
        queryUnique,
        targetIndexKey: currentPos.indexPos,
        targetObjectKey: currentPos.objectPos,
      });
    }

    return packResult();
  }

  // Continue past targetIndexKey (and optionally targetObjectKey)
  // in the direction specified by "forward".
  // Do nothing if the current position is already past the
  // target position.
  async _continueIndex(req: {
    indexId: SqliteRowid;
    indexUnique: boolean;
    queryUnique: boolean;
    forward: boolean;
    inclusive: boolean;
    currentPos: IndexIterPos | null | undefined;
    targetIndexKey: Uint8Array;
    targetObjectKey?: Uint8Array;
  }): Promise<IndexIterPos | undefined> {
    const currentPos = req.currentPos;
    const forward = req.forward;
    const dir = forward ? 1 : -1;
    if (currentPos) {
      // Check that the target position after the current position.
      // If not, we just stay at the current position.
      const indexCmp = compareSerializedKeys(
        currentPos.indexPos,
        req.targetIndexKey,
      );
      if (dir * indexCmp > 0) {
        return currentPos;
      }
      if (indexCmp === 0) {
        if (req.targetObjectKey != null) {
          const objectCmp = compareSerializedKeys(
            currentPos.objectPos,
            req.targetObjectKey,
          );
          if (req.inclusive && objectCmp === 0) {
            return currentPos;
          }
          if (dir * objectCmp > 0) {
            return currentPos;
          }
        } else if (req.inclusive) {
          return currentPos;
        }
      }
    }

    let stmt: Sqlite3Statement;

    if (req.indexUnique) {
      if (req.forward) {
        if (req.inclusive) {
          stmt = await this._prep(sqlUniqueIndexDataContinueForwardInclusive);
        } else {
          stmt = await this._prep(sqlUniqueIndexDataContinueForwardStrict);
        }
      } else {
        if (req.inclusive) {
          stmt = await this._prep(sqlUniqueIndexDataContinueBackwardInclusive);
        } else {
          stmt = await this._prep(sqlUniqueIndexDataContinueBackwardStrict);
        }
      }
    } else {
      if (req.forward) {
        if (req.queryUnique || req.targetObjectKey == null) {
          if (req.inclusive) {
            stmt = await this._prep(sqlIndexDataContinueForwardInclusiveUnique);
          } else {
            stmt = await this._prep(sqlIndexDataContinueForwardStrictUnique);
          }
        } else {
          if (req.inclusive) {
            stmt = await this._prep(sqlIndexDataContinueForwardInclusive);
          } else {
            stmt = await this._prep(sqlIndexDataContinueForwardStrict);
          }
        }
      } else {
        if (req.queryUnique || req.targetObjectKey == null) {
          if (req.inclusive) {
            stmt = await this._prep(
              sqlIndexDataContinueBackwardInclusiveUnique,
            );
          } else {
            stmt = await this._prep(sqlIndexDataContinueBackwardStrictUnique);
          }
        } else {
          if (req.inclusive) {
            stmt = await this._prep(sqlIndexDataContinueBackwardInclusive);
          } else {
            stmt = await this._prep(sqlIndexDataContinueBackwardStrict);
          }
        }
      }
    }

    const res = await stmt.getFirst({
      index_id: req.indexId,
      index_key: req.targetIndexKey,
      object_key: req.targetObjectKey,
    });

    if (res == null) {
      return undefined;
    }

    assertDbInvariant(typeof res === "object");
    assertDbInvariant("index_key" in res);
    const indexKey = res.index_key;
    if (indexKey == null) {
      return undefined;
    }
    assertDbInvariant(indexKey instanceof Uint8Array);
    assertDbInvariant("object_key" in res);
    const objectKey = res.object_key;
    if (objectKey == null) {
      return undefined;
    }
    assertDbInvariant(objectKey instanceof Uint8Array);

    return {
      indexPos: indexKey,
      objectPos: objectKey,
    };
  }

  async _startIndex(req: {
    indexId: SqliteRowid;
    indexUnique: boolean;
    queryUnique: boolean;
    forward: boolean;
  }): Promise<IndexIterPos | undefined> {
    let stmt: Sqlite3Statement;
    if (req.indexUnique) {
      if (req.forward) {
        stmt = await this._prep(sqlUniqueIndexDataStartForward);
      } else {
        stmt = await this._prep(sqlUniqueIndexDataStartBackward);
      }
    } else {
      if (req.forward) {
        stmt = await this._prep(sqlIndexDataStartForward);
      } else {
        if (req.queryUnique) {
          stmt = await this._prep(sqlIndexDataStartBackwardUnique);
        } else {
          stmt = await this._prep(sqlIndexDataStartBackward);
        }
      }
    }

    const res = await stmt.getFirst({
      index_id: req.indexId,
    });

    if (res == null) {
      return undefined;
    }

    assertDbInvariant(typeof res === "object");
    assertDbInvariant("index_key" in res);
    const indexKey = res.index_key;
    assertDbInvariant(indexKey instanceof Uint8Array);
    assertDbInvariant("object_key" in res);
    const objectKey = res.object_key;
    assertDbInvariant(objectKey instanceof Uint8Array);

    return {
      indexPos: indexKey,
      objectPos: objectKey,
    };
  }

  async getObjectStoreRecords(
    btx: DatabaseTransaction,
    req: ObjectStoreGetQuery,
  ): Promise<RecordGetResponse> {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    if (this.txLevel < TransactionLevel.Read) {
      throw Error("only allowed in read transaction");
    }
    const scopeInfo = connInfo.storeMap.get(req.objectStoreName);
    if (!scopeInfo) {
      throw Error(
        `object store ${JSON.stringify(
          req.objectStoreName,
        )} not in transaction scope, only have ${JSON.stringify(
          Object.keys(connInfo.storeMap),
        )}`,
      );
    }

    const forward: boolean =
      req.direction === "next" || req.direction === "nextunique";

    const objectStoreId = await this._provideObjectStore(connInfo, scopeInfo);

    let currentKey = await this._startObjectKey(objectStoreId, forward);

    if (req.advancePrimaryKey != null) {
      const targetKey = serializeKey(req.advancePrimaryKey);
      currentKey = await this._continueObjectKey({
        objectStoreId: objectStoreId,
        forward,
        inclusive: true,
        currentKey,
        targetKey,
      });
    }

    if (req.lastObjectStorePosition != null) {
      const targetKey = serializeKey(req.lastObjectStorePosition);
      currentKey = await this._continueObjectKey({
        objectStoreId: objectStoreId,
        forward,
        inclusive: false,
        currentKey,
        targetKey,
      });
    }

    if (req.range != null) {
      const targetKeyObj = forward ? req.range.lower : req.range.upper;
      if (targetKeyObj != null) {
        const targetKey = serializeKey(targetKeyObj);
        const inclusive = forward ? !req.range.lowerOpen : !req.range.upperOpen;
        currentKey = await this._continueObjectKey({
          objectStoreId: objectStoreId,
          forward,
          inclusive,
          currentKey,
          targetKey,
        });
      }
    }

    const endRange = getRangeEndBoundary(forward, req.range);

    let numResults = 0;
    const encPrimaryKeys: Uint8Array[] = [];
    const primaryKeys: IDBValidKey[] = [];
    const values: unknown[] = [];

    while (1) {
      if (req.limit != 0 && numResults == req.limit) {
        break;
      }
      if (currentKey == null) {
        break;
      }
      if (endRange && isOutsideBoundary(forward, endRange, currentKey)) {
        break;
      }

      numResults++;

      if (req.resultLevel > ResultLevel.OnlyCount) {
        encPrimaryKeys.push(currentKey);
      }

      currentKey = await this._continueObjectKey({
        objectStoreId: objectStoreId,
        forward,
        inclusive: false,
        currentKey: null,
        targetKey: currentKey,
      });
    }

    if (req.resultLevel > ResultLevel.OnlyCount) {
      for (let i = 0; i < encPrimaryKeys.length; i++) {
        primaryKeys.push(deserializeKey(encPrimaryKeys[i]));
      }
      if (req.resultLevel === ResultLevel.Full) {
        for (let i = 0; i < encPrimaryKeys.length; i++) {
          const val = await this._getObjectValue(
            objectStoreId,
            encPrimaryKeys[i],
          );
          if (!val) {
            throw Error("invariant failed: value not found");
          }
          values.push(structuredRevive(JSON.parse(val)));
        }
      }
    }

    if (this.trackStats) {
      const k = `${req.objectStoreName}`;
      this.accessStats.readsPerStore[k] =
        (this.accessStats.readsPerStore[k] ?? 0) + 1;
      this.accessStats.readItemsPerStore[k] =
        (this.accessStats.readItemsPerStore[k] ?? 0) + numResults;
    }

    return {
      count: numResults,
      indexKeys: undefined,
      primaryKeys:
        req.resultLevel >= ResultLevel.OnlyKeys ? primaryKeys : undefined,
      values: req.resultLevel >= ResultLevel.Full ? values : undefined,
    };
  }

  async _startObjectKey(
    objectStoreId: number | bigint,
    forward: boolean,
  ): Promise<Uint8Array | null> {
    let stmt: Sqlite3Statement;
    if (forward) {
      stmt = await this._prep(sqlObjectDataStartForward);
    } else {
      stmt = await this._prep(sqlObjectDataStartBackward);
    }
    const res = await stmt.getFirst({
      object_store_id: objectStoreId,
    });
    if (!res) {
      return null;
    }
    assertDbInvariant(typeof res === "object");
    assertDbInvariant("rkey" in res);
    const rkey = res.rkey;
    if (!rkey) {
      return null;
    }
    assertDbInvariant(rkey instanceof Uint8Array);
    return rkey;
  }

  // Result *must* be past targetKey in the direction
  // specified by "forward".
  async _continueObjectKey(req: {
    objectStoreId: number | bigint;
    forward: boolean;
    currentKey: Uint8Array | null;
    targetKey: Uint8Array;
    inclusive: boolean;
  }): Promise<Uint8Array | null> {
    const { forward, currentKey, targetKey } = req;
    const dir = forward ? 1 : -1;
    if (currentKey) {
      const objCmp = compareSerializedKeys(currentKey, targetKey);
      if (objCmp === 0 && req.inclusive) {
        return currentKey;
      }
      if (dir * objCmp > 0) {
        return currentKey;
      }
    }

    let stmt: Sqlite3Statement;

    if (req.inclusive) {
      if (req.forward) {
        stmt = await this._prep(sqlObjectDataContinueForwardInclusive);
      } else {
        stmt = await this._prep(sqlObjectDataContinueBackwardInclusive);
      }
    } else {
      if (req.forward) {
        stmt = await this._prep(sqlObjectDataContinueForward);
      } else {
        stmt = await this._prep(sqlObjectDataContinueBackward);
      }
    }

    const res = await stmt.getFirst({
      object_store_id: req.objectStoreId,
      x: req.targetKey,
    });

    if (!res) {
      return null;
    }

    assertDbInvariant(typeof res === "object");
    assertDbInvariant("rkey" in res);
    const rkey = res.rkey;
    if (!rkey) {
      return null;
    }
    assertDbInvariant(rkey instanceof Uint8Array);
    return rkey;
  }

  async _getObjectValue(
    objectStoreId: number | bigint,
    key: Uint8Array,
  ): Promise<string | undefined> {
    const stmt = await this._prep(sqlObjectDataValueFromKey);
    const res = await stmt.getFirst({
      object_store_id: objectStoreId,
      key: key,
    });
    if (!res) {
      return undefined;
    }
    assertDbInvariant(typeof res === "object");
    assertDbInvariant("value" in res);
    assertDbInvariant(typeof res.value === "string");
    return res.value;
  }

  getObjectStoreMeta(
    dbConn: DatabaseConnection,
    objectStoreName: string,
  ): ObjectStoreMeta | undefined {
    const connInfo = this.connectionMap.get(dbConn.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    const storeMeta = connInfo.storeMap.get(objectStoreName);
    if (!storeMeta) {
      return undefined;
    }
    return {
      keyPath: storeMeta.keyPath,
      autoIncrement: storeMeta.autoIncrement,
      indexSet: [...storeMeta.indexMap.keys()],
    };
  }

  getIndexMeta(
    dbConn: DatabaseConnection,
    objectStoreName: string,
    indexName: string,
  ): IndexMeta | undefined {
    // FIXME: Use cached info from the connection for this!
    const connInfo = this.connectionMap.get(dbConn.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    const storeMeta = connInfo.storeMap.get(objectStoreName);
    if (!storeMeta) {
      throw Error("object store not found");
    }
    const indexMeta = storeMeta.indexMap.get(indexName);
    if (!indexMeta) {
      return undefined;
    }
    return {
      keyPath: indexMeta.keyPath,
      multiEntry: indexMeta.multiEntry,
      unique: indexMeta.unique,
    };
  }

  async getDatabases(): Promise<BridgeIDBDatabaseInfo[]> {
    const dbList = await (await this._prep(sqlListDatabases)).getAll();
    let res: BridgeIDBDatabaseInfo[] = [];
    for (const r of dbList) {
      res.push({
        name: (r as any).name,
        version: (r as any).version,
      });
    }

    return res;
  }

  private async _loadObjectStoreNames(databaseName: string): Promise<string[]> {
    const objectStoreNames: string[] = [];
    const stmt = await this._prep(sqlGetObjectStoresByDatabase);
    const storesRes = await stmt.getAll({
      database_name: databaseName,
    });
    for (const res of storesRes) {
      assertDbInvariant(res != null && typeof res === "object");
      assertDbInvariant("name" in res);
      const storeName = res.name;
      assertDbInvariant(typeof storeName === "string");
      objectStoreNames.push(storeName);
    }
    return objectStoreNames;
  }

  async _runSqlBegin(): Promise<void> {
    const stmt = await this._prep(sqlBegin);
    await stmt.run();
  }

  async _runSqlCommit(): Promise<void> {
    const stmt = await this._prep(sqlCommit);
    await stmt.run();
  }

  async _runSqlGetDatabaseVersion(
    databaseName: string,
  ): Promise<number | undefined> {
    const versionRes = await (
      await this._prep(sqlGetDatabaseVersion)
    ).getFirst({
      name: databaseName,
    });
    if (versionRes == undefined) {
      return undefined;
    }
    const verNum = expectDbNumber(versionRes, "version");
    assertDbInvariant(typeof verNum === "number");
    return verNum;
  }

  async _runSqlCreateDatabase(databaseName: string): Promise<void> {
    const stmt = await this._prep(sqlCreateDatabase);
    await stmt.run({ name: databaseName });
  }

  async connectDatabase(databaseName: string): Promise<ConnectResult> {
    const connectionId = this.connectionIdCounter++;
    const connectionCookie = `connection-${connectionId}`;

    // Wait until no transaction is active anymore.
    while (1) {
      if (this.enableTracing) {
        console.log(`connectDatabase - txLevel is ${this.txLevel}`);
      }
      if (this.txLevel == TransactionLevel.None) {
        break;
      }
      await this.transactionDoneCond.wait();
    }

    this.txLevel = TransactionLevel.Write;

    await this._runSqlBegin();
    let ver = await this._runSqlGetDatabaseVersion(databaseName);
    if (ver == null) {
      await this._runSqlCreateDatabase(databaseName);
      ver = 0;
    }

    const objectStoreNames: string[] =
      await this._loadObjectStoreNames(databaseName);
    await this._runSqlCommit();

    const connInfo = {
      databaseName: databaseName,
      storeList: [],
      storeMap: new Map(),
    };

    this.connectionMap.set(connectionCookie, connInfo);

    for (const storeName of objectStoreNames) {
      await this._loadScopeInfo(connInfo, storeName);
    }

    this.txLevel = TransactionLevel.None;
    this.transactionDoneCond.trigger();

    return {
      conn: {
        connectionCookie,
      },
      version: ver,
      objectStores: objectStoreNames,
    };
  }

  private async _loadScopeInfo(
    connInfo: ConnectionInfo,
    storeName: string,
  ): Promise<void> {
    const objRes = await (
      await this._prep(sqlGetObjectStoreMetaByName)
    ).getFirst({
      name: storeName,
      database_name: connInfo.databaseName,
    });
    if (!objRes) {
      throw Error("object store not found");
    }
    const objectStoreId = expectDbNumber(objRes, "id");
    const objectStoreAutoIncrement = expectDbNumber(objRes, "auto_increment");
    const objectStoreKeyPath = deserializeKeyPath(
      expectDbStringOrNull(objRes, "key_path"),
    );
    const indexRes = await (
      await this._prep(sqlGetIndexesByObjectStoreId)
    ).getAll({
      object_store_id: objectStoreId,
    });
    if (!indexRes) {
      throw Error("db inconsistent");
    }
    const indexList: MyIndexMeta[] = [];
    const indexMap = new Map<string, MyIndexMeta>();
    for (const idxInfo of indexRes) {
      const indexId = expectDbNumber(idxInfo, "id");
      const indexName = expectDbString(idxInfo, "name");
      const indexUnique = expectDbNumber(idxInfo, "unique_index");
      const indexMultiEntry = expectDbNumber(idxInfo, "multientry");
      const indexKeyPath = deserializeKeyPath(
        expectDbString(idxInfo, "key_path"),
      );
      if (!indexKeyPath) {
        throw Error("db inconsistent");
      }
      const indexMeta: MyIndexMeta = {
        indexId,
        keyPath: indexKeyPath,
        multiEntry: indexMultiEntry != 0,
        unique: indexUnique != 0,
        currentName: indexName,
        nameDirty: false,
      };
      indexList.push(indexMeta);
      indexMap.set(indexName, indexMeta);
    }
    const storeMeta: MyStoreMeta = {
      objectStoreId,
      indexMap,
      indexList,
      autoIncrement: objectStoreAutoIncrement != 0,
      keyPath: objectStoreKeyPath,
      currentName: storeName,
      nameDirty: false,
    };
    connInfo.storeList.push(storeMeta);
    connInfo.storeMap.set(storeName, storeMeta);
  }

  async beginTransaction(
    conn: DatabaseConnection,
    objectStores: string[],
    mode: IDBTransactionMode,
  ): Promise<DatabaseTransaction> {
    const connInfo = this.connectionMap.get(conn.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    const transactionCookie = `tx-${this.transactionIdCounter++}`;

    while (1) {
      if (this.txLevel === TransactionLevel.None) {
        break;
      }
      await this.transactionDoneCond.wait();
    }

    if (this.trackStats) {
      if (mode === "readonly") {
        this.accessStats.readTransactions++;
      } else if (mode === "readwrite") {
        this.accessStats.writeTransactions++;
      }
    }

    if (mode === "readonly") {
      this.txLevel = TransactionLevel.Read;
    } else if (mode === "readwrite") {
      this.txLevel = TransactionLevel.Write;
    } else {
      throw Error("not supported");
    }

    await this._runSqlBegin();

    this.transactionMap.set(transactionCookie, {
      connectionCookie: conn.connectionCookie,
    });

    return {
      transactionCookie,
    };
  }

  async enterVersionChange(
    conn: DatabaseConnection,
    newVersion: number,
  ): Promise<DatabaseTransaction> {
    const connInfo = this.connectionMap.get(conn.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    if (this.enableTracing) {
      console.log(
        `entering version change transaction (conn ${conn.connectionCookie}), newVersion=${newVersion}`,
      );
    }
    const transactionCookie = `tx-vc-${this.transactionIdCounter++}`;

    while (1) {
      if (this.txLevel === TransactionLevel.None) {
        break;
      }
      await this.transactionDoneCond.wait();
    }

    if (this.enableTracing) {
      console.log(`version change transaction unblocked`);
    }

    this.txLevel = TransactionLevel.VersionChange;
    this.transactionMap.set(transactionCookie, {
      connectionCookie: conn.connectionCookie,
    });

    await this._runSqlBegin();
    await this._runSqlUpdateDbVersion(connInfo.databaseName, newVersion);

    return {
      transactionCookie,
    };
  }

  async _runSqlUpdateDbVersion(
    databaseName: string,
    newVersion: number,
  ): Promise<void> {
    const stmt = await this._prep(sqlUpdateDbVersion);
    await stmt.run({
      name: databaseName,
      version: newVersion,
    });
  }

  async deleteDatabase(databaseName: string): Promise<void> {
    // FIXME: Wait until connection queue is not blocked
    // FIXME: To properly implement the spec semantics, maybe
    // split delete into prepareDelete and executeDelete?

    while (this.txLevel !== TransactionLevel.None) {
      await this.transactionDoneCond.wait();
    }

    this.txLevel = TransactionLevel.VersionChange;

    await this._runSqlBegin();

    const objectStoreNames = await this._loadObjectStoreNames(databaseName);

    for (const storeName of objectStoreNames) {
      const objRes = await (
        await this._prep(sqlGetObjectStoreMetaByName)
      ).getFirst({
        name: storeName,
        database_name: databaseName,
      });
      if (!objRes) {
        throw Error("object store not found");
      }
      const objectStoreId = expectDbNumber(objRes, "id");
      const indexRes = await (
        await this._prep(sqlGetIndexesByObjectStoreId)
      ).getAll({
        object_store_id: objectStoreId,
      });
      if (!indexRes) {
        throw Error("db inconsistent");
      }
      const indexList: MyIndexMeta[] = [];
      for (const idxInfo of indexRes) {
        const indexId = expectDbNumber(idxInfo, "id");
        const indexName = expectDbString(idxInfo, "name");
        const indexUnique = expectDbNumber(idxInfo, "unique_index");
        const indexMultiEntry = expectDbNumber(idxInfo, "multientry");
        const indexKeyPath = deserializeKeyPath(
          expectDbString(idxInfo, "key_path"),
        );
        if (!indexKeyPath) {
          throw Error("db inconsistent");
        }
        const indexMeta: MyIndexMeta = {
          indexId,
          keyPath: indexKeyPath,
          multiEntry: indexMultiEntry != 0,
          unique: indexUnique != 0,
          currentName: indexName,
          nameDirty: false,
        };
        indexList.push(indexMeta);
      }

      for (const indexInfo of indexList) {
        let stmt: Sqlite3Statement;
        if (indexInfo.unique) {
          stmt = await this._prep(sqlIUniqueIndexDataDeleteAll);
        } else {
          stmt = await this._prep(sqlIndexDataDeleteAll);
        }
        await stmt.run({
          index_id: indexInfo.indexId,
        });
        await (
          await this._prep(sqlIndexDelete)
        ).run({
          index_id: indexInfo.indexId,
        });
      }
      await (
        await this._prep(sqlObjectDataDeleteAll)
      ).run({
        object_store_id: objectStoreId,
      });
      await (
        await this._prep(sqlObjectStoreDelete)
      ).run({
        object_store_id: objectStoreId,
      });
    }
    await (
      await this._prep(sqlDeleteDatabase)
    ).run({
      name: databaseName,
    });
    await (await this._prep(sqlCommit)).run();

    this.txLevel = TransactionLevel.None;
    this.transactionDoneCond.trigger();
  }

  async close(db: DatabaseConnection): Promise<void> {
    const connInfo = this.connectionMap.get(db.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    // Wait until no transaction is active anymore.
    while (1) {
      if (this.txLevel == TransactionLevel.None) {
        break;
      }
      await this.transactionDoneCond.wait();
    }
    if (this.enableTracing) {
      console.log(`closing connection ${db.connectionCookie}`);
    }
    this.connectionMap.delete(db.connectionCookie);
  }

  renameObjectStore(
    btx: DatabaseTransaction,
    oldName: string,
    newName: string,
  ): void {
    if (this.enableTracing) {
      console.log(`renaming object store '${oldName}' to '${newName}'`);
    }
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction required");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("not connected");
    }
    // FIXME: Would be much nicer with numeric UID handles
    const storeMeta = connInfo.storeMap.get(oldName);
    if (!storeMeta) {
      throw Error("object store not found");
    }
    connInfo.storeMap.delete(oldName);
    connInfo.storeMap.set(newName, storeMeta);
    storeMeta.currentName = newName;
    storeMeta.nameDirty = true;
  }

  renameIndex(
    btx: DatabaseTransaction,
    objectStoreName: string,
    oldIndexName: string,
    newIndexName: string,
  ): void {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction required");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("not connected");
    }
    // FIXME: Would be much nicer with numeric UID handles
    const scopeInfo = connInfo.storeMap.get(objectStoreName);
    if (!scopeInfo) {
      throw Error("object store not found");
    }
    const indexInfo = scopeInfo.indexMap.get(oldIndexName);
    if (!indexInfo) {
      throw Error("index not found");
    }
    // FIXME: Would also be much nicer with numeric UID handles
    scopeInfo.indexMap.delete(oldIndexName);
    scopeInfo.indexMap.set(newIndexName, indexInfo);
    scopeInfo.nameDirty = true;
    scopeInfo.currentName = newIndexName;
  }

  async _doDeleteObjectStore(scopeInfo: MyStoreMeta): Promise<void> {
    for (const indexInfo of scopeInfo.indexMap.values()) {
      let stmt: Sqlite3Statement;
      if (indexInfo.unique) {
        stmt = await this._prep(sqlIUniqueIndexDataDeleteAll);
      } else {
        stmt = await this._prep(sqlIndexDataDeleteAll);
      }
      await stmt.run({
        index_id: indexInfo.indexId,
      });
      await (
        await this._prep(sqlIndexDelete)
      ).run({
        index_id: indexInfo.indexId,
      });
    }
    await (
      await this._prep(sqlObjectDataDeleteAll)
    ).run({
      object_store_id: scopeInfo.objectStoreId,
    });
    await (
      await this._prep(sqlObjectStoreDelete)
    ).run({
      object_store_id: scopeInfo.objectStoreId,
    });
  }

  deleteObjectStore(btx: DatabaseTransaction, name: string): void {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction required");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("not connected");
    }
    // FIXME: Would be much nicer with numeric UID handles
    const scopeInfo = connInfo.storeMap.get(name);
    if (!scopeInfo) {
      throw Error("object store not found");
    }
    const storeMeta = connInfo.storeMap.get(name);
    if (!storeMeta) {
      throw Error("object store does not exist");
    }
    connInfo.storeMap.delete(name);
    storeMeta.currentName = undefined;
  }

  async _doDeleteIndex(indexMeta: MyIndexMeta): Promise<void> {
    let stmt: Sqlite3Statement;
    if (indexMeta.unique) {
      stmt = await this._prep(sqlIUniqueIndexDataDeleteAll);
    } else {
      stmt = await this._prep(sqlIndexDataDeleteAll);
    }
    await stmt.run({
      index_id: indexMeta.indexId,
    });
    await (
      await this._prep(sqlIndexDelete)
    ).run({
      index_id: indexMeta.indexId,
    });
  }

  deleteIndex(
    btx: DatabaseTransaction,
    objectStoreName: string,
    indexName: string,
  ): void {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction required");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("not connected");
    }
    // FIXME: Would be much nicer with numeric UID handles
    const storeMeta = connInfo.storeMap.get(objectStoreName);
    if (!storeMeta) {
      throw Error("object store not found");
    }
    const indexInfo = storeMeta.indexMap.get(indexName);
    if (!indexInfo) {
      throw Error("index not found");
    }
    storeMeta.indexMap.delete(indexName);
    indexInfo.currentName = undefined;
  }

  async rollback(btx: DatabaseTransaction): Promise<void> {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    if (this.enableTracing) {
      console.log(`rolling back transaction ${btx.transactionCookie}`);
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("not connected");
    }
    if (this.txLevel === TransactionLevel.None) {
      return;
    }
    await (await this._prep(sqlRollback)).run();
    if (this.txLevel === TransactionLevel.VersionChange) {
      // Rollback also undoes schema changes, but that is only
      // relevant in a versionchange transaction.
      connInfo.storeList = [];
      connInfo.storeMap.clear();
      const objectStoreNames: string[] = await this._loadObjectStoreNames(
        connInfo.databaseName,
      );
      for (const storeName of objectStoreNames) {
        await this._loadScopeInfo(connInfo, storeName);
      }
    }
    this.txLevel = TransactionLevel.None;
    this.transactionMap.delete(btx.transactionCookie);
    this.transactionDoneCond.trigger();
  }

  async commit(btx: DatabaseTransaction): Promise<void> {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("not connected");
    }
    if (this.enableTracing) {
      console.log(`committing transaction ${btx.transactionCookie}`);
    }
    if (this.txLevel === TransactionLevel.None) {
      return;
    }
    if (this.txLevel === TransactionLevel.VersionChange) {
      for (const store of connInfo.storeList) {
        if (store.currentName == null) {
          await this._doDeleteObjectStore(store);
          continue;
        }
        if (store.objectStoreId == null) {
          await this._provideObjectStore(connInfo, store);
        }
        if (store.nameDirty) {
          await (
            await this._prep(sqlRenameObjectStore)
          ).run({
            object_store_id: store.objectStoreId,
            name: store.currentName,
          });
        }
        for (const indexMeta of store.indexList) {
          if (indexMeta.currentName == null) {
            await this._doDeleteIndex(indexMeta);
            continue;
          }
          if (indexMeta.indexId == null) {
            await this._provideIndex(connInfo, store, indexMeta);
          }
          if (indexMeta.nameDirty) {
            await (
              await this._prep(sqlRenameIndex)
            ).run({
              index_id: indexMeta.indexId,
              name: indexMeta.currentName,
            });
            indexMeta.nameDirty = false;
          }
        }
      }
    }
    await (await this._prep(sqlCommit)).run();
    this.txLevel = TransactionLevel.None;
    this.transactionMap.delete(btx.transactionCookie);
    this.transactionDoneCond.trigger();
  }

  async _provideObjectStore(
    connInfo: ConnectionInfo,
    storeMeta: MyStoreMeta,
  ): Promise<SqliteRowid> {
    if (storeMeta.objectStoreId != null) {
      return storeMeta.objectStoreId;
    }
    if (!storeMeta.currentName) {
      throw Error("invalid state");
    }

    const runRes = await (
      await this._prep(sqlCreateObjectStore)
    ).run({
      name: storeMeta.currentName,
      key_path: serializeKeyPath(storeMeta.keyPath),
      auto_increment: storeMeta.autoIncrement ? 1 : 0,
      database_name: connInfo.databaseName,
    });

    storeMeta.objectStoreId = runRes.lastInsertRowid;

    for (const indexMeta of storeMeta.indexList) {
      if (indexMeta.currentName == null) {
        continue;
      }
      if (indexMeta.indexId != null) {
        throw Error("invariant violated");
      }
      await this._provideIndex(connInfo, storeMeta, indexMeta);
    }

    return runRes.lastInsertRowid;
  }

  createObjectStore(
    btx: DatabaseTransaction,
    name: string,
    keyPath: string | string[] | null,
    autoIncrement: boolean,
  ): void {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    if (this.txLevel < TransactionLevel.VersionChange) {
      throw Error("only allowed in versionchange transaction");
    }
    if (connInfo.storeMap.has(name)) {
      throw Error("object store already exists");
    }
    const storeMeta: MyStoreMeta = {
      objectStoreId: undefined,
      indexMap: new Map(),
      indexList: [],
      // Normalize
      keyPath: deserializeKeyPath(serializeKeyPath(keyPath)),
      autoIncrement: autoIncrement,
      currentName: name,
      nameDirty: false,
    };
    connInfo.storeList.push(storeMeta);
    connInfo.storeMap.set(name, storeMeta);
  }

  async _provideIndex(
    connInfo: ConnectionInfo,
    storeMeta: MyStoreMeta,
    indexMeta: MyIndexMeta,
  ) {
    if (indexMeta.indexId != null) {
      return indexMeta.indexId;
    }
    if (storeMeta.objectStoreId == null) {
      throw Error("invariant failed");
    }
    const res = await (
      await this._prep(sqlCreateIndex)
    ).run({
      object_store_id: storeMeta.objectStoreId,
      name: indexMeta.currentName,
      key_path: serializeKeyPath(indexMeta.keyPath),
      unique: indexMeta.unique ? 1 : 0,
      multientry: indexMeta.multiEntry ? 1 : 0,
    });
    indexMeta.indexId = res.lastInsertRowid;
    // FIXME: We can't use an iterator here, as it's not allowed to
    // execute a write statement while the iterator executes.
    // Maybe do multiple selects instead of loading everything into memory?
    const keyRowsRes = await (
      await this._prep(sqlObjectDataGetAll)
    ).getAll({
      object_store_id: storeMeta.objectStoreId,
    });

    for (const keyRow of keyRowsRes) {
      assertDbInvariant(typeof keyRow === "object" && keyRow != null);
      assertDbInvariant("key" in keyRow);
      assertDbInvariant("value" in keyRow);
      assertDbInvariant(typeof keyRow.value === "string");
      const key = keyRow.key;
      const value = structuredRevive(JSON.parse(keyRow.value));
      assertDbInvariant(key instanceof Uint8Array);
      try {
        await this.insertIntoIndex(indexMeta, key, value);
      } catch (e) {
        // FIXME: Catch this in insertIntoIndex!
        if (e instanceof DataError) {
          // https://www.w3.org/TR/IndexedDB-2/#object-store-storage-operation
          // Do nothing
        } else {
          throw e;
        }
      }
    }
    return res.lastInsertRowid;
  }

  createIndex(
    btx: DatabaseTransaction,
    indexName: string,
    objectStoreName: string,
    keyPath: string | string[],
    multiEntry: boolean,
    unique: boolean,
  ): void {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    if (this.txLevel < TransactionLevel.VersionChange) {
      throw Error("only allowed in versionchange transaction");
    }
    const scopeInfo = connInfo.storeMap.get(objectStoreName);
    if (!scopeInfo) {
      throw Error("object store does not exist, can't create index");
    }
    if (scopeInfo.indexMap.has(indexName)) {
      throw Error("index already exists");
    }

    if (this.enableTracing) {
      console.log(`creating index "${indexName}"`);
    }

    const scopeIndexInfo: MyIndexMeta = {
      indexId: undefined,
      keyPath,
      multiEntry,
      unique,
      currentName: indexName,
      nameDirty: false,
    };
    scopeInfo.indexList.push(scopeIndexInfo);
    scopeInfo.indexMap.set(indexName, scopeIndexInfo);
  }

  async deleteRecord(
    btx: DatabaseTransaction,
    objectStoreName: string,
    range: BridgeIDBKeyRange,
  ): Promise<void> {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    if (this.txLevel < TransactionLevel.Write) {
      throw Error("store operation only allowed while running a transaction");
    }
    const scopeInfo = connInfo.storeMap.get(objectStoreName);
    if (!scopeInfo) {
      throw Error(
        `object store ${JSON.stringify(
          objectStoreName,
        )} not in transaction scope`,
      );
    }

    const objectStoreId = await this._provideObjectStore(connInfo, scopeInfo);

    // PERF: We delete keys one-by-one here.
    // Instead, we could do it with a single
    // delete query for the object data / index data.

    let currKey: Uint8Array | null = null;

    if (range.lower != null) {
      const targetKey = serializeKey(range.lower);
      currKey = await this._continueObjectKey({
        objectStoreId: objectStoreId,
        currentKey: null,
        forward: true,
        inclusive: true,
        targetKey,
      });
    } else {
      currKey = await this._startObjectKey(objectStoreId, true);
    }

    let upperBound: Uint8Array | undefined;
    if (range.upper != null) {
      upperBound = serializeKey(range.upper);
    }

    // loop invariant: (currKey is undefined) or (currKey is a valid key)
    while (true) {
      if (!currKey) {
        break;
      }

      // FIXME: Check if we're past the range!
      if (upperBound != null) {
        const cmp = compareSerializedKeys(currKey, upperBound);
        if (cmp > 0) {
          break;
        }
        if (cmp == 0 && range.upperOpen) {
          break;
        }
      }

      // Now delete!

      await (
        await this._prep(sqlObjectDataDeleteKey)
      ).run({
        object_store_id: scopeInfo.objectStoreId,
        key: currKey,
      });

      for (const index of scopeInfo.indexMap.values()) {
        let stmt: Sqlite3Statement;
        if (index.unique) {
          stmt = await this._prep(sqlUniqueIndexDataDeleteKey);
        } else {
          stmt = await this._prep(sqlIndexDataDeleteKey);
        }
        await stmt.run({
          index_id: index.indexId,
          object_key: currKey,
        });
      }

      currKey = await this._continueObjectKey({
        objectStoreId: objectStoreId,
        currentKey: null,
        forward: true,
        inclusive: false,
        targetKey: currKey,
      });
    }
  }

  async storeRecord(
    btx: DatabaseTransaction,
    storeReq: RecordStoreRequest,
  ): Promise<RecordStoreResponse> {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    if (this.txLevel < TransactionLevel.Write) {
      throw Error("store operation only allowed while running a transaction");
    }
    const scopeInfo = connInfo.storeMap.get(storeReq.objectStoreName);
    if (!scopeInfo) {
      throw Error(
        `object store ${JSON.stringify(
          storeReq.objectStoreName,
        )} not in transaction scope`,
      );
    }
    const objectStoreId = await this._provideObjectStore(connInfo, scopeInfo);
    const metaRes = await (
      await this._prep(sqlGetObjectStoreMetaById)
    ).getFirst({
      id: objectStoreId satisfies SqliteRowid,
    });
    if (metaRes === undefined) {
      throw Error(
        `object store ${JSON.stringify(
          storeReq.objectStoreName,
        )} does not exist`,
      );
    }
    assertDbInvariant(!!metaRes && typeof metaRes === "object");
    assertDbInvariant("key_path" in metaRes);
    assertDbInvariant("auto_increment" in metaRes);
    const dbKeyPath = metaRes.key_path;
    assertDbInvariant(dbKeyPath === null || typeof dbKeyPath === "string");
    const keyPath = deserializeKeyPath(dbKeyPath);
    const autoIncrement = metaRes.auto_increment;
    assertDbInvariant(typeof autoIncrement === "number");

    let key;
    let value;
    let updatedKeyGenerator: number | undefined;

    if (storeReq.storeLevel === StoreLevel.UpdateExisting) {
      if (storeReq.key == null) {
        throw Error("invalid update request (key not given)");
      }
      key = storeReq.key;
      value = storeReq.value;
    } else {
      if (keyPath != null && storeReq.key !== undefined) {
        // If in-line keys are used, a key can't be explicitly specified.
        throw new DataError();
      }

      const storeKeyResult = makeStoreKeyValue({
        value: storeReq.value,
        key: storeReq.key,
        currentKeyGenerator: autoIncrement,
        autoIncrement: autoIncrement != 0,
        keyPath: keyPath,
      });

      if (autoIncrement != 0) {
        updatedKeyGenerator = storeKeyResult.updatedKeyGenerator;
      }

      key = storeKeyResult.key;
      value = storeKeyResult.value;
    }

    const serializedObjectKey = serializeKey(key);

    const existingObj = await this._getObjectValue(
      objectStoreId,
      serializedObjectKey,
    );

    if (storeReq.storeLevel === StoreLevel.NoOverwrite) {
      if (existingObj) {
        throw new ConstraintError();
      }
    }

    await (
      await this._prep(sqlInsertObjectData)
    ).run({
      object_store_id: objectStoreId,
      key: serializedObjectKey,
      value: JSON.stringify(structuredEncapsulate(value)),
    });

    if (autoIncrement != 0) {
      await (
        await this._prep(sqlUpdateAutoIncrement)
      ).run({
        object_store_id: objectStoreId,
        auto_increment: updatedKeyGenerator,
      });
    }

    for (const [k, indexInfo] of scopeInfo.indexMap.entries()) {
      const indexId = await this._provideIndex(connInfo, scopeInfo, indexInfo);
      if (existingObj) {
        await this.deleteFromIndex(
          indexId,
          indexInfo.unique,
          serializedObjectKey,
        );
      }

      try {
        await this.insertIntoIndex(indexInfo, serializedObjectKey, value);
      } catch (e) {
        // FIXME: handle this in insertIntoIndex!
        if (e instanceof DataError) {
          // We don't propagate this error here.
          continue;
        }
        throw e;
      }
    }

    if (this.trackStats) {
      this.accessStats.writesPerStore[storeReq.objectStoreName] =
        (this.accessStats.writesPerStore[storeReq.objectStoreName] ?? 0) + 1;
    }

    return {
      key: key,
    };
  }

  private async deleteFromIndex(
    indexId: SqliteRowid,
    indexUnique: boolean,
    objectKey: Uint8Array,
  ): Promise<void> {
    let stmt: Sqlite3Statement;
    if (indexUnique) {
      stmt = await this._prep(sqlUniqueIndexDataDeleteKey);
    } else {
      stmt = await this._prep(sqlIndexDataDeleteKey);
    }
    await stmt.run({
      index_id: indexId,
      object_key: objectKey,
    });
  }

  private async insertIntoIndex(
    indexInfo: MyIndexMeta,
    primaryKey: Uint8Array,
    value: any,
  ): Promise<void> {
    const indexKeys = getIndexKeys(
      value,
      indexInfo.keyPath,
      indexInfo.multiEntry,
    );
    if (!indexKeys.length) {
      return;
    }

    let stmt;
    if (indexInfo.unique) {
      stmt = await this._prep(sqlInsertUniqueIndexData);
    } else {
      stmt = await this._prep(sqlInsertIndexData);
    }

    for (const indexKey of indexKeys) {
      // FIXME: Re-throw correct error for unique index violations
      const serializedIndexKey = serializeKey(indexKey);
      try {
        await stmt.run({
          index_id: indexInfo.indexId,
          object_key: primaryKey,
          index_key: serializedIndexKey,
        });
      } catch (e: any) {
        if (e.code === SqliteError.constraintPrimarykey) {
          throw new ConstraintError();
        }
        throw e;
      }
    }
  }

  async clearObjectStore(
    btx: DatabaseTransaction,
    objectStoreName: string,
  ): Promise<void> {
    const txInfo = this.transactionMap.get(btx.transactionCookie);
    if (!txInfo) {
      throw Error("transaction not found");
    }
    const connInfo = this.connectionMap.get(txInfo.connectionCookie);
    if (!connInfo) {
      throw Error("connection not found");
    }
    if (this.txLevel < TransactionLevel.Write) {
      throw Error("store operation only allowed while running a transaction");
    }
    const scopeInfo = connInfo.storeMap.get(objectStoreName);
    if (!scopeInfo) {
      throw Error(
        `object store ${JSON.stringify(
          objectStoreName,
        )} not in transaction scope`,
      );
    }

    await (
      await this._prep(sqlClearObjectStore)
    ).run({
      object_store_id: scopeInfo.objectStoreId,
    });

    for (const index of scopeInfo.indexMap.values()) {
      let stmt: Sqlite3Statement;
      if (index.unique) {
        stmt = await this._prep(sqlClearUniqueIndexData);
      } else {
        stmt = await this._prep(sqlClearIndexData);
      }
      await stmt.run({
        index_id: index.indexId,
      });
    }
  }

  async backupToFile(path: string): Promise<void> {
    // Wait until no other transaction is active.
    while (this.txLevel !== TransactionLevel.None) {
      await this.transactionDoneCond.wait();
    }
    this.txLevel = TransactionLevel.VersionChange;
    const stmt = await this._prep("VACUUM INTO $filename;");
    await stmt.run({
      filename: path,
    });
    this.txLevel = TransactionLevel.None;
    this.transactionDoneCond.trigger();
  }
}

const schemaSql = `
BEGIN;
CREATE TABLE IF NOT EXISTS databases
( name TEXT PRIMARY KEY
, version INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS object_stores
( id INTEGER PRIMARY KEY
, database_name NOT NULL
, name TEXT NOT NULL
, key_path TEXT
, auto_increment INTEGER NOT NULL DEFAULT 0
, FOREIGN KEY (database_name)
    REFERENCES databases(name)
);

CREATE TABLE IF NOT EXISTS indexes
( id INTEGER PRIMARY KEY
, object_store_id INTEGER NOT NULL
, name TEXT NOT NULL
, key_path TEXT NOT NULL
, unique_index INTEGER NOT NULL
, multientry INTEGER NOT NULL
, FOREIGN KEY (object_store_id)
    REFERENCES object_stores(id)
);

CREATE TABLE IF NOT EXISTS object_data
( object_store_id INTEGER NOT NULL
, key BLOB NOT NULL
, value TEXT NOT NULL
, PRIMARY KEY (object_store_id, key)
);

CREATE TABLE IF NOT EXISTS index_data
( index_id INTEGER NOT NULL
, index_key BLOB NOT NULL
, object_key BLOB NOT NULL
, PRIMARY KEY (index_id, index_key, object_key)
, FOREIGN KEY (index_id)
    REFERENCES indexes(id)
);

CREATE TABLE IF NOT EXISTS unique_index_data
( index_id INTEGER NOT NULL
, index_key BLOB NOT NULL
, object_key BLOB NOT NULL
, PRIMARY KEY (index_id, index_key)
, FOREIGN KEY (index_id)
    REFERENCES indexes(id)
);
COMMIT;
`;

const sqlClearObjectStore = `
DELETE FROM object_data WHERE object_store_id=$object_store_id`;

const sqlClearIndexData = `
DELETE FROM index_data WHERE index_id=$index_id`;

const sqlClearUniqueIndexData = `
DELETE FROM unique_index_data WHERE index_id=$index_id`;

const sqlListDatabases = `
SELECT name, version FROM databases;
`;

const sqlGetDatabaseVersion = `
SELECT version FROM databases WHERE name=$name;
`;

const sqlBegin = `BEGIN;`;
const sqlCommit = `COMMIT;`;
const sqlRollback = `ROLLBACK;`;

const sqlCreateDatabase = `
INSERT INTO databases (name, version) VALUES ($name, 1);
`;

const sqlDeleteDatabase = `
DELETE FROM databases
WHERE name=$name;
`;

const sqlCreateObjectStore = `
INSERT INTO object_stores (name, database_name, key_path, auto_increment)
  VALUES ($name, $database_name, $key_path, $auto_increment);
`;

const sqlObjectStoreDelete = `
DELETE FROM object_stores
WHERE id=$object_store_id;`;

const sqlObjectDataDeleteAll = `
DELETE FROM object_data
WHERE object_store_id=$object_store_id`;

const sqlIndexDelete = `
DELETE FROM indexes
WHERE id=$index_id;
`;

const sqlIndexDataDeleteAll = `
DELETE FROM index_data
WHERE index_id=$index_id;
`;

const sqlIUniqueIndexDataDeleteAll = `
DELETE FROM unique_index_data
WHERE index_id=$index_id;
`;

const sqlCreateIndex = `
INSERT INTO indexes (object_store_id, name, key_path, unique_index, multientry)
  VALUES ($object_store_id, $name, $key_path, $unique, $multientry);
`;

const sqlInsertIndexData = `
INSERT INTO index_data (index_id, object_key, index_key)
  VALUES ($index_id, $object_key, $index_key);`;

const sqlInsertUniqueIndexData = `
INSERT INTO unique_index_data (index_id, object_key, index_key)
  VALUES ($index_id, $object_key, $index_key);`;

const sqlUpdateDbVersion = `
UPDATE databases
  SET version=$version
  WHERE name=$name;
`;

const sqlRenameObjectStore = `
UPDATE object_stores
  SET name=$name
  WHERE id=$object_store_id`;

const sqlRenameIndex = `
UPDATE indexes
  SET name=$name
  WHERE index_id=$index_id`;

const sqlGetObjectStoresByDatabase = `
SELECT id, name, key_path, auto_increment
FROM object_stores
WHERE database_name=$database_name;
`;

const sqlGetObjectStoreMetaById = `
SELECT key_path, auto_increment
FROM object_stores
WHERE id = $id;
`;

const sqlGetObjectStoreMetaByName = `
SELECT id, key_path, auto_increment
FROM object_stores
WHERE database_name=$database_name AND name=$name;
`;

const sqlGetIndexesByObjectStoreId = `
SELECT id, name, key_path, unique_index, multientry
FROM indexes
WHERE object_store_id=$object_store_id
`;

const sqlGetIndexByName = `
SELECT id, key_path, unique_index, multientry
FROM indexes
WHERE object_store_id=$object_store_id
  AND name=$name
`;

const sqlInsertObjectData = `
INSERT OR REPLACE INTO object_data(object_store_id, key, value)
  VALUES ($object_store_id, $key, $value);
`;

const sqlUpdateAutoIncrement = `
UPDATE object_stores
  SET auto_increment=$auto_increment
  WHERE id=$object_store_id
`;

const sqlObjectDataValueFromKey = `
SELECT value FROM object_data
  WHERE object_store_id=$object_store_id
  AND key=$key;
`;

const sqlObjectDataGetAll = `
SELECT key, value FROM object_data
  WHERE object_store_id=$object_store_id;`;

const sqlObjectDataStartForward = `
SELECT min(key) as rkey FROM object_data
  WHERE object_store_id=$object_store_id;`;

const sqlObjectDataStartBackward = `
SELECT max(key) as rkey FROM object_data
  WHERE object_store_id=$object_store_id;`;

const sqlObjectDataContinueForward = `
SELECT min(key) as rkey FROM object_data
  WHERE object_store_id=$object_store_id
  AND key > $x;`;

const sqlObjectDataContinueBackward = `
SELECT max(key) as rkey FROM object_data
  WHERE object_store_id=$object_store_id
  AND key < $x;`;

const sqlObjectDataContinueForwardInclusive = `
SELECT min(key) as rkey FROM object_data
  WHERE object_store_id=$object_store_id
  AND key >= $x;`;

const sqlObjectDataContinueBackwardInclusive = `
SELECT max(key) as rkey FROM object_data
  WHERE object_store_id=$object_store_id
  AND key <= $x;`;

const sqlObjectDataDeleteKey = `
DELETE FROM object_data
  WHERE object_store_id=$object_store_id AND
  key=$key`;

const sqlIndexDataDeleteKey = `
DELETE FROM index_data
  WHERE index_id=$index_id AND
  object_key=$object_key;
`;

const sqlUniqueIndexDataDeleteKey = `
DELETE FROM unique_index_data
  WHERE index_id=$index_id AND
  object_key=$object_key;
`;

// "next" or "nextunique" on a non-unique index
const sqlIndexDataStartForward = `
SELECT index_key, object_key FROM index_data
  WHERE index_id=$index_id
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// start a "next" or "nextunique" on a unique index
const sqlUniqueIndexDataStartForward = `
SELECT index_key, object_key FROM unique_index_data
  WHERE index_id=$index_id
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// start a "prev" or "prevunique" on a unique index
const sqlUniqueIndexDataStartBackward = `
SELECT index_key, object_key FROM unique_index_data
  WHERE index_id=$index_id
  ORDER BY index_key DESC, object_key DESC
  LIMIT 1
`;

// start a "prevunique" query on a non-unique index
const sqlIndexDataStartBackwardUnique = `
SELECT index_key, object_key FROM index_data
  WHERE index_id=$index_id
  ORDER BY index_key DESC, object_key ASC
  LIMIT 1
`;

// start a "prev" query on a non-unique index
const sqlIndexDataStartBackward = `
SELECT index_key, object_key FROM index_data
  WHERE index_id=$index_id
  ORDER BY index_key DESC, object_key DESC
  LIMIT 1
`;

// continue a "next" query, strictly go to a further key
const sqlIndexDataContinueForwardStrict = `
SELECT index_key, object_key FROM index_data
  WHERE
    index_id=$index_id AND
    ((index_key = $index_key AND object_key > $object_key) OR
    (index_key > $index_key))
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// continue a "next" query, go to at least the specified key
const sqlIndexDataContinueForwardInclusive = `
SELECT index_key, object_key FROM index_data
  WHERE
    index_id=$index_id AND
    ((index_key = $index_key AND object_key >= $object_key) OR
    (index_key > $index_key))
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// continue a "prev" query
const sqlIndexDataContinueBackwardStrict = `
SELECT index_key, object_key FROM index_data
  WHERE
    index_id=$index_id AND
    ((index_key = $index_key AND object_key < $object_key) OR
    (index_key < $index_key))
  ORDER BY index_key DESC, object_key DESC
  LIMIT 1;
`;

// continue a "prev" query
const sqlIndexDataContinueBackwardInclusive = `
SELECT index_key, object_key FROM index_data
  WHERE
    index_id=$index_id AND
    ((index_key = $index_key AND object_key <= $object_key) OR
    (index_key < $index_key))
  ORDER BY index_key DESC, object_key DESC
  LIMIT 1;
`;

// continue a "prevunique" query
const sqlIndexDataContinueBackwardStrictUnique = `
SELECT index_key, object_key FROM index_data
  WHERE index_id=$index_id AND index_key < $index_key
  ORDER BY index_key DESC, object_key ASC
  LIMIT 1;
`;

// continue a "prevunique" query
const sqlIndexDataContinueBackwardInclusiveUnique = `
SELECT index_key, object_key FROM index_data
  WHERE index_id=$index_id AND index_key <= $index_key
  ORDER BY index_key DESC, object_key ASC
  LIMIT 1;
`;

// continue a "next" query, no target object key
const sqlIndexDataContinueForwardStrictUnique = `
SELECT index_key, object_key FROM index_data
  WHERE index_id=$index_id AND index_key > $index_key
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// continue a "next" query, no target object key
const sqlIndexDataContinueForwardInclusiveUnique = `
SELECT index_key, object_key FROM index_data
  WHERE index_id=$index_id AND index_key >= $index_key
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// continue a "next" query, strictly go to a further key
const sqlUniqueIndexDataContinueForwardStrict = `
SELECT index_key, object_key FROM unique_index_data
  WHERE index_id=$index_id AND index_key > $index_key
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// continue a "next" query, go to at least the specified key
const sqlUniqueIndexDataContinueForwardInclusive = `
SELECT index_key, object_key FROM unique_index_data
  WHERE index_id=$index_id AND index_key >= $index_key
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// continue a "prev" query
const sqlUniqueIndexDataContinueBackwardStrict = `
SELECT index_key, object_key FROM unique_index_data
  WHERE index_id=$index_id AND index_key < $index_key
  ORDER BY index_key, object_key
  LIMIT 1;
`;

// continue a "prev" query
const sqlUniqueIndexDataContinueBackwardInclusive = `
SELECT index_key, object_key FROM unique_index_data
  WHERE index_id=$index_id AND index_key <= $index_key
  ORDER BY index_key DESC, object_key DESC
  LIMIT 1;
`;

export interface SqliteBackendOptions {
  filename: string;
}

export async function createSqliteBackend(
  sqliteImpl: Sqlite3Interface,
  options: SqliteBackendOptions,
): Promise<SqliteBackend> {
  const db = await sqliteImpl.open(options.filename);
  await db.exec("PRAGMA foreign_keys = ON;");
  await db.exec(schemaSql);
  return new SqliteBackend(sqliteImpl, db);
}
