/*
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
*/
/**
* 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 { 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";
import {
Sqlite3Database,
Sqlite3Interface,
Sqlite3Statement,
} from "./sqlite3-interface.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;
}
interface TransactionInfo {
connectionCookie: string;
}
interface ScopeIndexInfo {
indexId: SqliteRowid;
keyPath: IDBKeyPath | IDBKeyPath[];
multiEntry: boolean;
unique: boolean;
}
interface ScopeInfo {
/**
* Internal ID of the object store.
* Used for fast retrieval, since it's the
* primary key / rowid of the sqlite table.
*/
objectStoreId: SqliteRowid;
indexMap: Map;
}
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");
}
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");
}
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");
}
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();
/**
* Is the connection blocked because either an open request
* or delete request is being processed?
*/
private connectionBlocked: boolean = false;
private txLevel: TransactionLevel = TransactionLevel.None;
private txScope: Map = new Map();
private connectionMap: Map = new Map();
private transactionMap: Map = new Map();
private sqlPrepCache: Map = new Map();
enableTracing: boolean = true;
constructor(
public sqliteImpl: Sqlite3Interface,
public db: Sqlite3Database,
) {}
private _prep(sql: string): Sqlite3Statement {
const stmt = this.sqlPrepCache.get(sql);
if (stmt) {
return stmt;
}
const newStmt = this.db.prepare(sql);
this.sqlPrepCache.set(sql, newStmt);
return newStmt;
}
async getIndexRecords(
btx: DatabaseTransaction,
req: IndexGetQuery,
): Promise {
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 = this.txScope.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)",
);
}
}
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 = indexInfo.indexId;
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;
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 = backendThis._getObjectValue(
scopeInfo!.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 = 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 = 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 = 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 = 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 = 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.
_continueIndex(req: {
indexId: SqliteRowid;
indexUnique: boolean;
queryUnique: boolean;
forward: boolean;
inclusive: boolean;
currentPos: IndexIterPos | null | undefined;
targetIndexKey: Uint8Array;
targetObjectKey?: Uint8Array;
}): 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 = this._prep(sqlUniqueIndexDataContinueForwardInclusive);
} else {
stmt = this._prep(sqlUniqueIndexDataContinueForwardStrict);
}
} else {
if (req.inclusive) {
stmt = this._prep(sqlUniqueIndexDataContinueBackwardInclusive);
} else {
stmt = this._prep(sqlUniqueIndexDataContinueBackwardStrict);
}
}
} else {
if (req.forward) {
if (req.queryUnique || req.targetObjectKey == null) {
if (req.inclusive) {
stmt = this._prep(sqlIndexDataContinueForwardInclusiveUnique);
} else {
stmt = this._prep(sqlIndexDataContinueForwardStrictUnique);
}
} else {
if (req.inclusive) {
stmt = this._prep(sqlIndexDataContinueForwardInclusive);
} else {
stmt = this._prep(sqlIndexDataContinueForwardStrict);
}
}
} else {
if (req.queryUnique || req.targetObjectKey == null) {
if (req.inclusive) {
stmt = this._prep(sqlIndexDataContinueBackwardInclusiveUnique);
} else {
stmt = this._prep(sqlIndexDataContinueBackwardStrictUnique);
}
} else {
if (req.inclusive) {
stmt = this._prep(sqlIndexDataContinueBackwardInclusive);
} else {
stmt = this._prep(sqlIndexDataContinueBackwardStrict);
}
}
}
}
const res = 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,
};
}
_startIndex(req: {
indexId: SqliteRowid;
indexUnique: boolean;
queryUnique: boolean;
forward: boolean;
}): IndexIterPos | undefined {
let stmt: Sqlite3Statement;
if (req.indexUnique) {
if (req.forward) {
stmt = this._prep(sqlUniqueIndexDataStartForward);
} else {
stmt = this._prep(sqlUniqueIndexDataStartBackward);
}
} else {
if (req.forward) {
stmt = this._prep(sqlIndexDataStartForward);
} else {
if (req.queryUnique) {
stmt = this._prep(sqlIndexDataStartBackwardUnique);
} else {
stmt = this._prep(sqlIndexDataStartBackward);
}
}
}
const res = 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 {
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 = this.txScope.get(req.objectStoreName);
if (!scopeInfo) {
throw Error(
`object store ${JSON.stringify(
req.objectStoreName,
)} not in transaction scope`,
);
}
const forward: boolean =
req.direction === "next" || req.direction === "nextunique";
let currentKey = this._startObjectKey(scopeInfo.objectStoreId, forward);
if (req.advancePrimaryKey != null) {
const targetKey = serializeKey(req.advancePrimaryKey);
currentKey = this._continueObjectKey({
objectStoreId: scopeInfo.objectStoreId,
forward,
inclusive: true,
currentKey,
targetKey,
});
}
if (req.lastObjectStorePosition != null) {
const targetKey = serializeKey(req.lastObjectStorePosition);
currentKey = this._continueObjectKey({
objectStoreId: scopeInfo.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 = this._continueObjectKey({
objectStoreId: scopeInfo.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 = this._continueObjectKey({
objectStoreId: scopeInfo.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 = this._getObjectValue(
scopeInfo.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,
};
}
_startObjectKey(
objectStoreId: number | bigint,
forward: boolean,
): Uint8Array | null {
let stmt: Sqlite3Statement;
if (forward) {
stmt = this._prep(sqlObjectDataStartForward);
} else {
stmt = this._prep(sqlObjectDataStartBackward);
}
const res = 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".
_continueObjectKey(req: {
objectStoreId: number | bigint;
forward: boolean;
currentKey: Uint8Array | null;
targetKey: Uint8Array;
inclusive: boolean;
}): 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 = this._prep(sqlObjectDataContinueForwardInclusive);
} else {
stmt = this._prep(sqlObjectDataContinueBackwardInclusive);
}
} else {
if (req.forward) {
stmt = this._prep(sqlObjectDataContinueForward);
} else {
stmt = this._prep(sqlObjectDataContinueBackward);
}
}
const res = 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;
}
_getObjectValue(
objectStoreId: number | bigint,
key: Uint8Array,
): string | undefined {
const stmt = this._prep(sqlObjectDataValueFromKey);
const res = 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 {
// FIXME: Use cached info from the connection for this!
const connInfo = this.connectionMap.get(dbConn.connectionCookie);
if (!connInfo) {
throw Error("connection not found");
}
const objRes = this._prep(sqlGetObjectStoreMetaByName).getFirst({
name: objectStoreName,
database_name: connInfo.databaseName,
});
if (!objRes) {
throw Error("object store not found");
}
const objectStoreId = expectDbNumber(objRes, "id");
const keyPath = deserializeKeyPath(
expectDbStringOrNull(objRes, "key_path"),
);
const autoInc = expectDbNumber(objRes, "auto_increment");
const indexSet: string[] = [];
const indexRes = this._prep(sqlGetIndexesByObjectStoreId).getAll({
object_store_id: objectStoreId,
});
for (const idxInfo of indexRes) {
const indexName = expectDbString(idxInfo, "name");
indexSet.push(indexName);
}
return {
keyPath,
autoIncrement: autoInc != 0,
indexSet,
};
}
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 objRes = this._prep(sqlGetObjectStoreMetaByName).getFirst({
name: objectStoreName,
database_name: connInfo.databaseName,
});
if (!objRes) {
throw Error("object store not found");
}
const objectStoreId = expectDbNumber(objRes, "id");
const idxInfo = this._prep(sqlGetIndexByName).getFirst({
object_store_id: objectStoreId,
name: indexName,
});
if (!idxInfo) {
throw Error(
`index ${indexName} on object store ${objectStoreName} not found`,
);
}
const indexUnique = expectDbNumber(idxInfo, "unique_index");
const indexMultiEntry = expectDbNumber(idxInfo, "multientry");
const indexKeyPath = deserializeKeyPath(
expectDbString(idxInfo, "key_path"),
);
if (!indexKeyPath) {
throw Error("db inconsistent");
}
return {
keyPath: indexKeyPath,
multiEntry: indexMultiEntry != 0,
unique: indexUnique != 0,
};
}
async getDatabases(): Promise {
const dbList = 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 _loadObjectStoreNames(databaseName: string): string[] {
const objectStoreNames: string[] = [];
const storesRes = this._prep(sqlGetObjectStoresByDatabase).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 connectDatabase(databaseName: string): Promise {
const connectionId = this.connectionIdCounter++;
const connectionCookie = `connection-${connectionId}`;
// Wait until no transaction is active anymore.
while (1) {
if (this.txLevel == TransactionLevel.None) {
break;
}
await this.transactionDoneCond.wait();
}
this._prep(sqlBegin).run();
const versionRes = this._prep(sqlGetDatabaseVersion).getFirst({
name: databaseName,
});
let ver: number;
if (versionRes == undefined) {
this._prep(sqlCreateDatabase).run({ name: databaseName });
ver = 0;
} else {
const verNum = expectDbNumber(versionRes, "version");
assertDbInvariant(typeof verNum === "number");
ver = verNum;
}
const objectStoreNames: string[] = this._loadObjectStoreNames(databaseName);
this._prep(sqlCommit).run();
this.connectionMap.set(connectionCookie, {
databaseName: databaseName,
});
return {
conn: {
connectionCookie,
},
version: ver,
objectStores: objectStoreNames,
};
}
private _loadScopeInfo(connInfo: ConnectionInfo, storeName: string): void {
const objRes = this._prep(sqlGetObjectStoreMetaByName).getFirst({
name: storeName,
database_name: connInfo.databaseName,
});
if (!objRes) {
throw Error("object store not found");
}
const objectStoreId = expectDbNumber(objRes, "id");
const indexRes = this._prep(sqlGetIndexesByObjectStoreId).getAll({
object_store_id: objectStoreId,
});
if (!indexRes) {
throw Error("db inconsistent");
}
const indexMap = new Map();
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");
}
indexMap.set(indexName, {
indexId,
keyPath: indexKeyPath,
multiEntry: indexMultiEntry != 0,
unique: indexUnique != 0,
});
}
this.txScope.set(storeName, {
objectStoreId,
indexMap,
});
}
async beginTransaction(
conn: DatabaseConnection,
objectStores: string[],
mode: IDBTransactionMode,
): Promise {
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++;
}
}
this._prep(sqlBegin).run();
if (mode === "readonly") {
this.txLevel = TransactionLevel.Read;
} else if (mode === "readwrite") {
this.txLevel = TransactionLevel.Write;
}
this.transactionMap.set(transactionCookie, {
connectionCookie: conn.connectionCookie,
});
// FIXME: We should check this
// if (this.txScope.size != 0) {
// // Something didn't clean up!
// throw Error("scope not empty");
// }
this.txScope.clear();
// FIXME: Use cached info from connection?
for (const storeName of objectStores) {
this._loadScopeInfo(connInfo, storeName);
}
return {
transactionCookie,
};
}
async enterVersionChange(
conn: DatabaseConnection,
newVersion: number,
): Promise {
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();
}
// FIXME: We should check this
// if (this.txScope.size != 0) {
// // Something didn't clean up!
// throw Error("scope not empty");
// }
this.txScope.clear();
if (this.enableTracing) {
console.log(`version change transaction unblocked`);
}
this._prep(sqlBegin).run();
this.txLevel = TransactionLevel.VersionChange;
this.transactionMap.set(transactionCookie, {
connectionCookie: conn.connectionCookie,
});
this._prep(sqlUpdateDbVersion).run({
name: connInfo.databaseName,
version: newVersion,
});
const objectStoreNames = this._loadObjectStoreNames(connInfo.databaseName);
// FIXME: Use cached info from connection?
for (const storeName of objectStoreNames) {
this._loadScopeInfo(connInfo, storeName);
}
return {
transactionCookie,
};
}
async deleteDatabase(databaseName: string): Promise {
// 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._prep(sqlBegin).run();
const objectStoreNames = this._loadObjectStoreNames(databaseName);
for (const storeName of objectStoreNames) {
const objRes = this._prep(sqlGetObjectStoreMetaByName).getFirst({
name: storeName,
database_name: databaseName,
});
if (!objRes) {
throw Error("object store not found");
}
const objectStoreId = expectDbNumber(objRes, "id");
const indexRes = this._prep(sqlGetIndexesByObjectStoreId).getAll({
object_store_id: objectStoreId,
});
if (!indexRes) {
throw Error("db inconsistent");
}
const indexMap = new Map();
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");
}
indexMap.set(indexName, {
indexId,
keyPath: indexKeyPath,
multiEntry: indexMultiEntry != 0,
unique: indexUnique != 0,
});
}
this.txScope.set(storeName, {
objectStoreId,
indexMap,
});
for (const indexInfo of indexMap.values()) {
let stmt: Sqlite3Statement;
if (indexInfo.unique) {
stmt = this._prep(sqlIUniqueIndexDataDeleteAll);
} else {
stmt = this._prep(sqlIndexDataDeleteAll);
}
stmt.run({
index_id: indexInfo.indexId,
});
this._prep(sqlIndexDelete).run({
index_id: indexInfo.indexId,
});
}
this._prep(sqlObjectDataDeleteAll).run({
object_store_id: objectStoreId,
});
this._prep(sqlObjectStoreDelete).run({
object_store_id: objectStoreId,
});
}
this._prep(sqlDeleteDatabase).run({
name: databaseName,
});
this._prep(sqlCommit).run();
}
async close(db: DatabaseConnection): Promise {
const connInfo = this.connectionMap.get(db.connectionCookie);
if (!connInfo) {
throw Error("connection not found");
}
// FIXME: What if we're in a transaction? Does the backend interface allow this?
// if (this.txLevel !== TransactionLevel.None) {
// throw Error("can't close while in transaction");
// }
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 scopeInfo = this.txScope.get(oldName);
if (!scopeInfo) {
throw Error("object store not found");
}
this.txScope.delete(oldName);
this.txScope.set(newName, scopeInfo);
this._prep(sqlRenameObjectStore).run({
object_store_id: scopeInfo.objectStoreId,
name: newName,
});
}
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 = this.txScope.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);
this._prep(sqlRenameIndex).run({
index_id: indexInfo.indexId,
name: newIndexName,
});
}
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 = this.txScope.get(name);
if (!scopeInfo) {
throw Error("object store not found");
}
for (const indexInfo of scopeInfo.indexMap.values()) {
let stmt: Sqlite3Statement;
if (indexInfo.unique) {
stmt = this._prep(sqlIUniqueIndexDataDeleteAll);
} else {
stmt = this._prep(sqlIndexDataDeleteAll);
}
stmt.run({
index_id: indexInfo.indexId,
});
this._prep(sqlIndexDelete).run({
index_id: indexInfo.indexId,
});
}
this._prep(sqlObjectDataDeleteAll).run({
object_store_id: scopeInfo.objectStoreId,
});
this._prep(sqlObjectStoreDelete).run({
object_store_id: scopeInfo.objectStoreId,
});
this.txScope.delete(name);
}
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 scopeInfo = this.txScope.get(objectStoreName);
if (!scopeInfo) {
throw Error("object store not found");
}
const indexInfo = scopeInfo.indexMap.get(indexName);
if (!indexInfo) {
throw Error("index not found");
}
scopeInfo.indexMap.delete(indexName);
let stmt: Sqlite3Statement;
if (indexInfo.unique) {
stmt = this._prep(sqlIUniqueIndexDataDeleteAll);
} else {
stmt = this._prep(sqlIndexDataDeleteAll);
}
stmt.run({
index_id: indexInfo.indexId,
});
this._prep(sqlIndexDelete).run({
index_id: indexInfo.indexId,
});
}
async rollback(btx: DatabaseTransaction): Promise {
const txInfo = this.transactionMap.get(btx.transactionCookie);
if (!txInfo) {
throw Error("transaction not found");
}
if (this.enableTracing) {
console.log(`rolling back transaction ${btx.transactionCookie}`);
}
if (this.txLevel === TransactionLevel.None) {
return;
}
this._prep(sqlRollback).run();
this.txLevel = TransactionLevel.None;
this.transactionMap.delete(btx.transactionCookie);
this.txScope.clear();
this.transactionDoneCond.trigger();
}
async commit(btx: DatabaseTransaction): Promise {
const txInfo = this.transactionMap.get(btx.transactionCookie);
if (!txInfo) {
throw Error("transaction not found");
}
if (this.enableTracing) {
console.log(`committing transaction ${btx.transactionCookie}`);
}
if (this.txLevel === TransactionLevel.None) {
return;
}
this._prep(sqlCommit).run();
this.txLevel = TransactionLevel.None;
this.txScope.clear();
this.transactionMap.delete(btx.transactionCookie);
this.transactionDoneCond.trigger();
}
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 (this.txScope.has(name)) {
throw Error("object store already exists");
}
let myKeyPath = serializeKeyPath(keyPath);
const runRes = this._prep(sqlCreateObjectStore).run({
name,
key_path: myKeyPath,
auto_increment: autoIncrement ? 1 : 0,
database_name: connInfo.databaseName,
});
this.txScope.set(name, {
objectStoreId: runRes.lastInsertRowid,
indexMap: new Map(),
});
}
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 = this.txScope.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 res = this._prep(sqlCreateIndex).run({
object_store_id: scopeInfo.objectStoreId,
name: indexName,
key_path: serializeKeyPath(keyPath),
unique: unique ? 1 : 0,
multientry: multiEntry ? 1 : 0,
});
const scopeIndexInfo: ScopeIndexInfo = {
indexId: res.lastInsertRowid,
keyPath,
multiEntry,
unique,
};
scopeInfo.indexMap.set(indexName, scopeIndexInfo);
// 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 = this._prep(sqlObjectDataGetAll).getAll({
object_store_id: scopeInfo.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 {
this.insertIntoIndex(scopeIndexInfo, 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;
}
}
}
}
async deleteRecord(
btx: DatabaseTransaction,
objectStoreName: string,
range: BridgeIDBKeyRange,
): Promise {
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 = this.txScope.get(objectStoreName);
if (!scopeInfo) {
throw Error(
`object store ${JSON.stringify(
objectStoreName,
)} not in transaction scope`,
);
}
// 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 = this._continueObjectKey({
objectStoreId: scopeInfo.objectStoreId,
currentKey: null,
forward: true,
inclusive: true,
targetKey,
});
} else {
currKey = this._startObjectKey(scopeInfo.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!
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 = this._prep(sqlUniqueIndexDataDeleteKey);
} else {
stmt = this._prep(sqlIndexDataDeleteKey);
}
stmt.run({
index_id: index.indexId,
object_key: currKey,
});
}
currKey = this._continueObjectKey({
objectStoreId: scopeInfo.objectStoreId,
currentKey: null,
forward: true,
inclusive: false,
targetKey: currKey,
});
}
}
async storeRecord(
btx: DatabaseTransaction,
storeReq: RecordStoreRequest,
): Promise {
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 = this.txScope.get(storeReq.objectStoreName);
if (!scopeInfo) {
throw Error(
`object store ${JSON.stringify(
storeReq.objectStoreName,
)} not in transaction scope`,
);
}
const metaRes = this._prep(sqlGetObjectStoreMetaById).getFirst({
id: scopeInfo.objectStoreId,
});
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 = this._getObjectValue(
scopeInfo.objectStoreId,
serializedObjectKey,
);
if (storeReq.storeLevel === StoreLevel.NoOverwrite) {
if (existingObj) {
throw new ConstraintError();
}
}
this._prep(sqlInsertObjectData).run({
object_store_id: scopeInfo.objectStoreId,
key: serializedObjectKey,
value: JSON.stringify(structuredEncapsulate(value)),
});
if (autoIncrement != 0) {
this._prep(sqlUpdateAutoIncrement).run({
object_store_id: scopeInfo.objectStoreId,
auto_increment: updatedKeyGenerator,
});
}
for (const [k, indexInfo] of scopeInfo.indexMap.entries()) {
if (existingObj) {
this.deleteFromIndex(
indexInfo.indexId,
indexInfo.unique,
serializedObjectKey,
);
}
try {
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 deleteFromIndex(
indexId: SqliteRowid,
indexUnique: boolean,
objectKey: Uint8Array,
): void {
let stmt: Sqlite3Statement;
if (indexUnique) {
stmt = this._prep(sqlUniqueIndexDataDeleteKey);
} else {
stmt = this._prep(sqlIndexDataDeleteKey);
}
stmt.run({
index_id: indexId,
object_key: objectKey,
});
}
private insertIntoIndex(
indexInfo: ScopeIndexInfo,
primaryKey: Uint8Array,
value: any,
): void {
const indexKeys = getIndexKeys(
value,
indexInfo.keyPath,
indexInfo.multiEntry,
);
if (!indexKeys.length) {
return;
}
let stmt;
if (indexInfo.unique) {
stmt = this._prep(sqlInsertUniqueIndexData);
} else {
stmt = this._prep(sqlInsertIndexData);
}
for (const indexKey of indexKeys) {
// FIXME: Re-throw correct error for unique index violations
const serializedIndexKey = serializeKey(indexKey);
try {
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 {
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 = this.txScope.get(objectStoreName);
if (!scopeInfo) {
throw Error(
`object store ${JSON.stringify(
objectStoreName,
)} not in transaction scope`,
);
}
this._prep(sqlClearObjectStore).run({
object_store_id: scopeInfo.objectStoreId,
});
for (const index of scopeInfo.indexMap.values()) {
let stmt: Sqlite3Statement;
if (index.unique) {
stmt = this._prep(sqlClearUniqueIndexData);
} else {
stmt = this._prep(sqlClearIndexData);
}
stmt.run({
index_id: index.indexId,
});
}
}
}
const schemaSql = `
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)
);
`;
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 {
const db = sqliteImpl.open(options.filename);
db.exec("PRAGMA foreign_keys = ON;");
db.exec(schemaSql);
return new SqliteBackend(sqliteImpl, db);
}