/* 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); }