import { CRUDEngine , error as StoreEngineError } from '@wireapp/store-engine' ; import * as websql from '@wireapp/websql' ; import fs from 'fs' ; import { RESERVED_COLUMN , SQLeetEnginePrimaryKeyName , SQLiteDatabaseDefinition , SQLiteProvidedSchema , SQLiteTableDefinition , SQLiteType , createTableIfNotExists , escape , getFormattedColumnsFromColumns , getFormattedColumnsFromTableName , getProtectedColumnReferences , hashColumnName , isSingleColumnTable , } from './SchemaConverter' ; declare const WebAssembly ; export class SQLeetEngine implements CRUDEngine { private autoIncrementedPrimaryKey : number = 1 ; private readonly db : websql . Database ; private readonly schema : SQLiteDatabaseDefinition < string > = { } ; public storeName = '' ; constructor ( private readonly workerLocation , providedSchema , private readonly encryptionKey , private readonly nodeDatabaseDir ? ) { for ( const [ tableName , entity ] of Object . entries ( providedSchema ) ) { const isSingleColumnTable = typeof entity === 'string' ; this . schema [ tableName ] = isSingleColumnTable ? { [ RESERVED_COLUMN ] : entity as SQLiteType } : ( entity as SQLiteTableDefinition < string > ) ; } if ( nodeDatabaseDir ) { fs . mkdirSync ( nodeDatabaseDir , { recursive : true } ) ; } try { this . db = new websql . Database ( this . workerLocation , { allowMainWebWorker : true , allowWebWorkerFallback : true , } ) ; } catch ( error ) { throw new Error ( `An error happened while initializing the engine: ${ ( error as Error ) . message }` ) ; } } async init ( storeName ) { await this . isSupported ( ) ; this . storeName = storeName ; await this . db . mount ( { key : this . encryptionKey } , this . storeName , this . nodeDatabaseDir ) ; const statement = Object . entries ( this . schema ) . map ( ( [ tableName , table ] ) => createTableIfNotExists ( tableName , table ) ) . join ( '' ) ; await this . db . run ( statement ) ; return this . db ; } async clearTables ( ) { const tableNames = Object . keys ( this . schema ) ; await Promise . all ( tableNames . map ( tableName => this . deleteAll ( tableName ) ) ) ; } async export ( ) { return this . db . export ( 'utf8' ) ; } async purge ( ) { if ( this . db ) { await this . db . close ( ) ; await this . db . wipe ( this . storeName ) ; } } private buildValues < EntityType = Record < string , SQLiteType > > ( tableName , providedEntities ) { const table = this . schema [ tableName ] ; if ( ! table ) { throw new Error ( `Table "${ tableName }" does not exist.` ) ; } if ( providedEntities instanceof Object ) { for ( const [ key , property ] of Object . entries ( providedEntities ) ) { if ( property === null || property === undefined ) { delete ( providedEntities as any ) [ key ] ; } } } const entities = isSingleColumnTable ( table ) ? ( { [ RESERVED_COLUMN ] : providedEntities } as any as EntityType ) : ( providedEntities as EntityType ) ; const columns = { } ; const values = { } ; for ( const entity in entities ) { if ( typeof table [ entity ] !== 'string' ) { continue ; } let value = entities [ entity ] ; if ( table [ entity ] === SQLiteType . JSON || ( table [ entity ] === SQLiteType . JSON_OR_TEXT && value instanceof Object ) ) { value = JSON . stringify ( value ) as SQLiteType ; } const reference = `@${ hashColumnName ( entity ) }` ; columns [ reference ] = entity ; values [ reference ] = value ; } if ( Object . keys ( columns ) . length === 0 ) { throw new Error ( `Entity is empty for table "${ tableName }". Are you sure you set the right scheme / column names?` ) ; } return { columns , values } ; } private mapJSONProperties < EntityType = Object > ( table , entities ) { for ( const record in entities ) { for ( const column in entities [ record ] ) { if ( table [ column ] === SQLiteType . JSON ) { entities [ record ] [ column ] = JSON . parse ( entities [ record ] [ column ] as any ) ; } else if ( table [ column ] === SQLiteType . JSON_OR_TEXT ) { try { entities [ record ] [ column ] = JSON . parse ( entities [ record ] [ column ] as any ) ; } catch ( error ) { } } } } return entities ; } async create < EntityType = Object , PrimaryKey = string > ( tableName , primaryKey , entity ) { if ( ! entity ) { const message = `Record "${ primaryKey }" cannot be saved in "${ tableName }" because it's "undefined" or "null".` ; throw new StoreEngineError . RecordTypeError ( message ) ; } if ( primaryKey === undefined ) { primaryKey = this . autoIncrementedPrimaryKey as unknown as PrimaryKey ; this . autoIncrementedPrimaryKey += 1 ; } const { columns , values } = this . buildValues ( tableName , entity ) ; const newValues = Object . keys ( values ) . join ( ',' ) ; const escapedTableName = escape ( tableName ) ; const statement = `INSERT INTO ${ escapedTableName } (${ getFormattedColumnsFromColumns ( columns , true ) }) VALUES (@primaryKey,${ newValues });` ; try { await this . db . run ( statement , { ... values , '@primaryKey' : primaryKey , } ) ; } catch ( error ) { if ( ( error as Error ) . message . startsWith ( `UNIQUE constraint failed: ` ) ) { const message = `Record "${ primaryKey }" already exists in "${ tableName }". You need to delete the record first if you want to overwrite it.` ; throw new StoreEngineError . RecordAlreadyExistsError ( message ) ; } else { throw error ; } } return primaryKey ; } async delete < PrimaryKey = string > ( tableName , primaryKey ) { const escapedTableName = escape ( tableName ) ; const statement = `DELETE FROM ${ escapedTableName } WHERE ${ SQLeetEnginePrimaryKeyName }=@primaryKey;` ; await this . db . run ( statement , { '@primaryKey' : primaryKey , } ) ; return primaryKey ; } async deleteAll ( tableName ) { const escapedTableName = escape ( tableName ) ; const statement = `DELETE FROM ${ escapedTableName }` ; await this . db . run ( statement ) ; return true ; } async read < EntityType = Object , PrimaryKey = string > ( tableName , primaryKey ) { const table = this . schema [ tableName ] ; if ( ! table ) { throw new Error ( `Table "${ tableName }" does not exist.` ) ; } const columns = getFormattedColumnsFromTableName ( table ) ; const escapedTableName = escape ( tableName ) ; const selectRecordStatement = `SELECT ${ columns } FROM ${ escapedTableName } WHERE ${ SQLeetEnginePrimaryKeyName }=@primaryKey;` ; const statement = await this . db . prepare ( selectRecordStatement , { '@primaryKey' : primaryKey , } ) ; const entities = await statement . getAsObject ( ) ; const [ record ] = this . mapJSONProperties ( table , entities ) ; await statement . free ( ) ; if ( typeof record === 'undefined' ) { const message = `Record "${ primaryKey }" in "${ tableName }" could not be found.` ; throw new StoreEngineError . RecordNotFoundError ( message ) ; } if ( isSingleColumnTable ( table ) ) { return record [ RESERVED_COLUMN ] ; } return record as EntityType ; } async readAll < T > ( tableName ) { const table = this . schema [ tableName ] ; const columns = getFormattedColumnsFromTableName ( table ) ; const escapedTableName = escape ( tableName ) ; const selectRecordStatement = `SELECT ${ columns } FROM ${ escapedTableName };` ; const statement = await this . db . prepare ( selectRecordStatement ) ; const entities = ( await statement . getAsObject ( ) ) as T [ ] ; await statement . free ( ) ; return this . mapJSONProperties ( table , entities ) ; } async readAllPrimaryKeys ( tableName ) { const escapedTableName = escape ( tableName ) ; const statement = `SELECT ${ SQLeetEnginePrimaryKeyName } FROM ${ escapedTableName };` ; const record = await this . db . execute ( statement ) ; if ( record [ 0 ] ?. values ) { return record [ 0 ] . values . map ( ( value ) => value [ 0 ] ) ; } return [ ] ; } async update < PrimaryKey = string , ChangesType = Object > ( tableName , primaryKey , changes ) { await this . read ( tableName , primaryKey ) ; const { values , columns } = this . buildValues ( tableName , changes ) ; const escapedTableName = escape ( tableName ) ; const references = getProtectedColumnReferences ( columns ) ; const statement = `UPDATE ${ escapedTableName } SET ${ references } WHERE ${ SQLeetEnginePrimaryKeyName }=@primaryKey;` ; await this . db . run ( statement , { ... values , '@primaryKey' : primaryKey , } ) ; return primaryKey ; } async updateOrCreate < PrimaryKey = string , ChangesType = Object > ( tableName , primaryKey , changes ) { try { await this . update ( tableName , primaryKey , changes ) ; } catch ( error ) { const isRecordNotFound = error instanceof StoreEngineError . RecordNotFoundError || ( error as Error ) . constructor . name === StoreEngineError . RecordNotFoundError . name ; if ( isRecordNotFound ) { return this . create ( tableName , primaryKey , changes ) ; } throw error ; } return primaryKey ; } /** Should be called when done with all database operations. In Node.js it is important to call this function when done, otherwise the Node.js "process" cannot finish. */ async close ( ) { await this . db . close ( ) ; const workerInstance = await this . db . _getWorkerInstance ( ) ; if ( workerInstance . terminate && typeof workerInstance . terminate == 'function' ) { workerInstance . terminate ( ) ; } } /** Should be called in browser environments to persist data in IndexedDB. Otherwise the data will be lost as it only exists in memory. */ save ( ) { return this . db . saveChanges ( ) ; } async isSupported ( ) { if ( typeof WebAssembly === 'object' && typeof WebAssembly . instantiate === 'function' ) { const module = new WebAssembly . Module ( Uint8Array . of ( 0x0 , 0x61 , 0x73 , 0x6d , 0x01 , 0x00 , 0x00 , 0x00 ) ) ; if ( module instanceof WebAssembly . Module ) { if ( new WebAssembly . Instance ( module ) instanceof WebAssembly . Instance ) { return ; } } } throw new StoreEngineError . UnsupportedError ( 'WebAssembly is not supported.' ) ; } }