diff --git a/.eslintrc.js b/.eslintrc.js index 1b4ee6949..deda33df6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -271,6 +271,7 @@ testsConfig['rules']['padded-blocks'] = [ switches: 'never', }, ]; +testsConfig['rules']['jest/expect-expect'] = 'off'; testsConfig['plugins'].push('jest'); testsConfig['extends'].push('plugin:jest/recommended'); diff --git a/scripts/print-performance-measures.js b/scripts/print-performance-measures.js index 2b611be01..24019b117 100755 --- a/scripts/print-performance-measures.js +++ b/scripts/print-performance-measures.js @@ -75,5 +75,13 @@ for (const [name, { duration, scripting, styling, blocking, longTasks, database, }; } +// Sort tests +const tests = Object.keys(performanceMeasures).sort(); +const sortedPerformanceMeasures = {}; + +for (const test of tests) { + sortedPerformanceMeasures[test] = performanceMeasures[test]; +} + // Display data -console.table(performanceMeasures); +console.table(sortedPerformanceMeasures); diff --git a/src/core/classes/database-table.ts b/src/core/classes/database-table.ts deleted file mode 100644 index 5e6ea32ee..000000000 --- a/src/core/classes/database-table.ts +++ /dev/null @@ -1,194 +0,0 @@ -// (C) Copyright 2015 Moodle Pty Ltd. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { SQLiteDB, SQLiteDBRecordValues } from './sqlitedb'; - -/** - * Database table wrapper used to improve performance by caching all data in memory - * for faster read operations. - */ -export abstract class CoreDatabaseTable< - DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, - PrimaryKeyColumns extends keyof DBRecord = 'id', - PrimaryKey extends GetPrimaryKey = GetPrimaryKey -> { - - /** - * Create an instance. - * - * @param db Database connection. - * @returns Instance. - */ - static async create(this: CoreDatabaseTableConstructor, db: SQLiteDB): Promise { - const instance = new this(db); - - await instance.initialize(); - - return instance; - } - - protected db: SQLiteDB; - protected data: Record; - protected primaryKeys: string[] = ['id']; - - constructor(db: SQLiteDB) { - this.db = db; - this.data = {}; - } - - /** - * Find a record matching the given conditions. - * - * @param conditions Matching conditions. - * @returns Database record. - */ - find(conditions: Partial): DBRecord | null { - return Object.values(this.data).find(record => this.recordMatches(record, conditions)) ?? null; - } - - /** - * Find a record by its primary key. - * - * @param primaryKey Primary key. - * @returns Database record. - */ - findByPrimaryKey(primaryKey: PrimaryKey): DBRecord | null { - return this.data[this.serializePrimaryKey(primaryKey)] ?? null; - } - - /** - * Insert a new record. - * - * @param record Database record. - */ - async insert(record: DBRecord): Promise { - await this.db.insertRecord(this.table, record); - - const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record)); - - this.data[primaryKey] = record; - } - - /** - * Delete records matching the given conditions. - * - * @param conditions Matching conditions. If this argument is missing, all records will be deleted. - */ - async delete(conditions?: Partial): Promise { - if (!conditions) { - await this.db.deleteRecords(this.table); - - this.data = {}; - - return; - } - - await this.db.deleteRecords(this.table, conditions); - - Object.entries(this.data).forEach(([id, record]) => { - if (!this.recordMatches(record, conditions)) { - return; - } - - delete this.data[id]; - }); - } - - /** - * Delete a single record identified by its primary key. - * - * @param primaryKey Record primary key. - */ - async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { - await this.db.deleteRecords(this.table, primaryKey); - - delete this.data[this.serializePrimaryKey(primaryKey)]; - } - - /** - * Database table name. - */ - protected abstract get table(): string; - - /** - * Initialize object by getting the current state of the database table. - */ - protected async initialize(): Promise { - const records = await this.db.getRecords(this.table); - - this.data = records.reduce((data, record) => { - const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record)); - - data[primaryKey] = record; - - return data; - }, {}); - } - - /** - * Get an object with the columns representing the primary key of a database record. - * - * @param record Database record. - * @returns Primary key column-value pairs. - */ - protected getPrimaryKeyFromRecord(record: DBRecord): PrimaryKey { - return this.primaryKeys.reduce((primaryKey, column) => { - primaryKey[column] = record[column]; - - return primaryKey; - }, {} as Record) as PrimaryKey; - } - - /** - * Serialize a primary key with a string representation. - * - * @param primaryKey Database record primary key. - * @returns Serialized primary key. - */ - protected serializePrimaryKey(primaryKey: PrimaryKey): string { - return Object.values(primaryKey).map(value => String(value)).join('-'); - } - - /** - * Check whether a given record matches the given conditions. - * - * @param record Database record. - * @param conditions Conditions. - * @returns Whether the record matches the conditions. - */ - protected recordMatches(record: DBRecord, conditions: Partial): boolean { - return !Object.entries(conditions).some(([column, value]) => record[column] !== value); - } - -} - -/** - * Generic type to match against any concrete database table type. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyCoreDatabaseTable = CoreDatabaseTable>; - -/** - * Database table constructor. - */ -type CoreDatabaseTableConstructor = { - new (db: SQLiteDB): T; -}; - -/** - * Infer primary key type from database record and columns types. - */ -type GetPrimaryKey = { - [column in PrimaryKeyColumns]: DBRecord[column]; -}; diff --git a/src/core/classes/database/database-table-proxy.ts b/src/core/classes/database/database-table-proxy.ts new file mode 100644 index 000000000..58393f943 --- /dev/null +++ b/src/core/classes/database/database-table-proxy.ts @@ -0,0 +1,219 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreConstants } from '@/core/constants'; +import { asyncInstance } from '@/core/utils/async-instance'; +import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreConfigProvider } from '@services/config'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreDatabaseReducer, CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey } from './database-table'; +import { CoreDebugDatabaseTable } from './debug-database-table'; +import { CoreEagerDatabaseTable } from './eager-database-table'; +import { CoreLazyDatabaseTable } from './lazy-database-table'; + +/** + * Database table proxy used to route database interactions through different implementations. + * + * This class allows using a database wrapper with different optimization strategies that can be changed at runtime. + */ +export class CoreDatabaseTableProxy< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> extends CoreDatabaseTable { + + protected config: CoreDatabaseConfiguration; + protected target = asyncInstance>(); + protected environmentObserver?: CoreEventObserver; + + constructor( + config: Partial, + database: SQLiteDB, + tableName: string, + primaryKeyColumns?: PrimaryKeyColumn[], + ) { + super(database, tableName, primaryKeyColumns); + + this.config = { ...this.getConfigDefaults(), ...config }; + } + + /** + * @inheritdoc + */ + async initialize(): Promise { + this.environmentObserver = CoreEvents.on(CoreConfigProvider.ENVIRONMENT_UPDATED, () => this.updateTarget()); + + await this.updateTarget(); + } + + /** + * @inheritdoc + */ + async destroy(): Promise { + this.environmentObserver?.off(); + } + + /** + * @inheritdoc + */ + async getMany(conditions?: Partial): Promise { + return this.target.getMany(conditions); + } + + /** + * @inheritdoc + */ + async getOne(conditions: Partial): Promise { + return this.target.getOne(conditions); + } + + /** + * @inheritdoc + */ + async getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { + return this.target.getOneByPrimaryKey(primaryKey); + } + + /** + * @inheritdoc + */ + async reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { + return this.target.reduce(reducer, conditions); + } + + /** + * @inheritdoc + */ + async insert(record: DBRecord): Promise { + return this.target.insert(record); + } + + /** + * @inheritdoc + */ + async update(updates: Partial, conditions?: Partial): Promise { + return this.target.update(updates, conditions); + } + + /** + * @inheritdoc + */ + async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + return this.target.updateWhere(updates, conditions); + } + + /** + * @inheritdoc + */ + async delete(conditions?: Partial): Promise { + return this.target.delete(conditions); + } + + /** + * @inheritdoc + */ + async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + return this.target.deleteByPrimaryKey(primaryKey); + } + + /** + * Get default configuration values. + * + * @returns Config defaults. + */ + protected getConfigDefaults(): CoreDatabaseConfiguration { + return { + cachingStrategy: CoreDatabaseCachingStrategy.None, + debug: false, + }; + } + + /** + * Get database configuration to use at runtime. + * + * @returns Database configuration. + */ + protected getRuntimeConfig(): CoreDatabaseConfiguration { + return { + ...this.config, + ...CoreConstants.CONFIG.databaseOptimizations, + ...CoreConstants.CONFIG.databaseTableOptimizations?.[this.tableName], + }; + } + + /** + * Update underlying target instance. + */ + protected async updateTarget(): Promise { + const oldTarget = this.target.instance; + const newTarget = this.createTarget(); + + if (oldTarget) { + await oldTarget.destroy(); + + this.target.resetInstance(); + } + + await newTarget.initialize(); + + this.target.setInstance(newTarget); + } + + /** + * Create proxy target. + * + * @returns Target instance. + */ + protected createTarget(): CoreDatabaseTable { + const config = this.getRuntimeConfig(); + const table = this.createTable(config.cachingStrategy); + + return config.debug ? new CoreDebugDatabaseTable(table) : table; + } + + /** + * Create a database table using the given caching strategy. + * + * @param cachingStrategy Caching strategy. + * @returns Database table. + */ + protected createTable(cachingStrategy: CoreDatabaseCachingStrategy): CoreDatabaseTable { + switch (cachingStrategy) { + case CoreDatabaseCachingStrategy.Eager: + return new CoreEagerDatabaseTable(this.database, this.tableName, this.primaryKeyColumns); + case CoreDatabaseCachingStrategy.Lazy: + return new CoreLazyDatabaseTable(this.database, this.tableName, this.primaryKeyColumns); + case CoreDatabaseCachingStrategy.None: + return new CoreDatabaseTable(this.database, this.tableName, this.primaryKeyColumns); + } + } + +} + +/** + * Database proxy configuration. + */ +export interface CoreDatabaseConfiguration { + cachingStrategy: CoreDatabaseCachingStrategy; + debug: boolean; +} + +/** + * Database caching strategies. + */ +export enum CoreDatabaseCachingStrategy { + Eager = 'eager', + Lazy = 'lazy', + None = 'none', +} diff --git a/src/core/classes/database/database-table.ts b/src/core/classes/database/database-table.ts new file mode 100644 index 000000000..f018b062c --- /dev/null +++ b/src/core/classes/database/database-table.ts @@ -0,0 +1,240 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SQLiteDB, SQLiteDBRecordValue, SQLiteDBRecordValues } from '@classes/sqlitedb'; + +/** + * Wrapper used to interact with a database table. + */ +export class CoreDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> { + + protected database: SQLiteDB; + protected tableName: string; + protected primaryKeyColumns: PrimaryKeyColumn[]; + + constructor(database: SQLiteDB, tableName: string, primaryKeyColumns?: PrimaryKeyColumn[]) { + this.database = database; + this.tableName = tableName; + this.primaryKeyColumns = primaryKeyColumns ?? ['id'] as PrimaryKeyColumn[]; + } + + /** + * Get database connection. + * + * @returns Database connection. + */ + getDatabase(): SQLiteDB { + return this.database; + } + + /** + * Get table name. + * + * @returns Table name. + */ + getTableName(): string { + return this.tableName; + } + + /** + * Get primary key columns. + * + * @returns Primary key columns. + */ + getPrimaryKeyColumns(): PrimaryKeyColumn[] { + return this.primaryKeyColumns.slice(0); + } + + /** + * Initialize. + */ + async initialize(): Promise { + // Nothing to initialize by default, override this method if necessary. + } + + /** + * Destroy. + */ + async destroy(): Promise { + // Nothing to destroy by default, override this method if necessary. + } + + /** + * Get records matching the given conditions. + * + * @param conditions Matching conditions. If this argument is missing, all records in the table will be returned. + * @returns Database records. + */ + getMany(conditions?: Partial): Promise { + return conditions + ? this.database.getRecords(this.tableName, conditions) + : this.database.getAllRecords(this.tableName); + } + + /** + * Find one record matching the given conditions. + * + * @param conditions Matching conditions. + * @returns Database record. + */ + getOne(conditions: Partial): Promise { + return this.database.getRecord(this.tableName, conditions); + } + + /** + * Find one record by its primary key. + * + * @param primaryKey Primary key. + * @returns Database record. + */ + getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { + return this.database.getRecord(this.tableName, primaryKey); + } + + /** + * Reduce some records into a single value. + * + * @param reducer Reducer functions in SQL and JavaScript. + * @param conditions Matching conditions in SQL and JavaScript. If this argument is missing, all records in the table + * will be used. + * @returns Reduced value. + */ + reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { + return this.database.getFieldSql( + `SELECT ${reducer.sql} FROM ${this.tableName} ${conditions?.sql ?? ''}`, + conditions?.sqlParams, + ) as unknown as Promise; + } + + /** + * Insert a new record. + * + * @param record Database record. + */ + async insert(record: DBRecord): Promise { + await this.database.insertRecord(this.tableName, record); + } + + /** + * Update records matching the given conditions. + * + * @param updates Record updates. + * @param conditions Matching conditions. If this argument is missing, all records will be updated. + */ + async update(updates: Partial, conditions?: Partial): Promise { + await this.database.updateRecords(this.tableName, updates, conditions); + } + + /** + * Update records matching the given conditions. + * + * This method should be used when it's necessary to apply complex conditions; the simple `update` + * method should be favored otherwise for better performance. + * + * @param updates Record updates. + * @param conditions Matching conditions in SQL and JavaScript. + */ + async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + await this.database.updateRecordsWhere(this.tableName, updates, conditions.sql, conditions.sqlParams); + } + + /** + * Delete records matching the given conditions. + * + * @param conditions Matching conditions. If this argument is missing, all records will be deleted. + */ + async delete(conditions?: Partial): Promise { + conditions + ? await this.database.deleteRecords(this.tableName, conditions) + : await this.database.deleteRecords(this.tableName); + } + + /** + * Delete a single record identified by its primary key. + * + * @param primaryKey Record primary key. + */ + async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + await this.database.deleteRecords(this.tableName, primaryKey); + } + + /** + * Get the primary key from a database record. + * + * @param record Database record. + * @returns Primary key. + */ + protected getPrimaryKeyFromRecord(record: DBRecord): PrimaryKey { + return this.primaryKeyColumns.reduce((primaryKey, column) => { + primaryKey[column] = record[column]; + + return primaryKey; + }, {} as Record) as PrimaryKey; + } + + /** + * Serialize a primary key with a string representation. + * + * @param primaryKey Primary key. + * @returns Serialized primary key. + */ + protected serializePrimaryKey(primaryKey: PrimaryKey): string { + return Object.values(primaryKey).map(value => String(value)).join('-'); + } + + /** + * Check whether a given record matches the given conditions. + * + * @param record Database record. + * @param conditions Matching conditions. + * @returns Whether the record matches the conditions. + */ + protected recordMatches(record: DBRecord, conditions: Partial): boolean { + return !Object.entries(conditions).some(([column, value]) => record[column] !== value); + } + +} + +/** + * Infer primary key type from database record and primary key column types. + */ +export type GetDBRecordPrimaryKey = { + [column in PrimaryKeyColumn]: DBRecord[column]; +}; + +/** + * Reducer used to accumulate a value from multiple records both in SQL and JavaScript. + * + * Both operations should be equivalent. + */ +export type CoreDatabaseReducer = { + sql: string; + js: (previousValue: T, record: DBRecord) => T; + jsInitialValue: T; +}; + +/** + * Conditions to match database records both in SQL and JavaScript. + * + * Both conditions should be equivalent. + */ +export type CoreDatabaseConditions = { + sql: string; + sqlParams?: SQLiteDBRecordValue[]; + js: (record: DBRecord) => boolean; +}; diff --git a/src/core/classes/database/debug-database-table.ts b/src/core/classes/database/debug-database-table.ts new file mode 100644 index 000000000..209c9abb1 --- /dev/null +++ b/src/core/classes/database/debug-database-table.ts @@ -0,0 +1,139 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreLogger } from '@singletons/logger'; +import { CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey, CoreDatabaseReducer } from './database-table'; + +/** + * Database table proxy used to debug runtime operations. + * + * This proxy should only be used for development purposes. + */ +export class CoreDebugDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> extends CoreDatabaseTable { + + protected target: CoreDatabaseTable; + protected logger: CoreLogger; + + constructor(target: CoreDatabaseTable) { + super(target.getDatabase(), target.getTableName(), target.getPrimaryKeyColumns()); + + this.target = target; + this.logger = CoreLogger.getInstance(`CoreDatabase[${this.tableName}]`); + } + + /** + * @inheritdoc + */ + initialize(): Promise { + this.logger.log('initialize'); + + return this.target.initialize(); + } + + /** + * @inheritdoc + */ + destroy(): Promise { + this.logger.log('destroy'); + + return this.target.destroy(); + } + + /** + * @inheritdoc + */ + getMany(conditions?: Partial): Promise { + this.logger.log('getMany', conditions); + + return this.target.getMany(conditions); + } + + /** + * @inheritdoc + */ + getOne(conditions: Partial): Promise { + this.logger.log('getOne', conditions); + + return this.target.getOne(conditions); + } + + /** + * @inheritdoc + */ + getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { + this.logger.log('findByPrimaryKey', primaryKey); + + return this.target.getOneByPrimaryKey(primaryKey); + } + + /** + * @inheritdoc + */ + reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { + this.logger.log('reduce', reducer, conditions); + + return this.target.reduce(reducer, conditions); + } + + /** + * @inheritdoc + */ + insert(record: DBRecord): Promise { + this.logger.log('insert', record); + + return this.target.insert(record); + } + + /** + * @inheritdoc + */ + update(updates: Partial, conditions?: Partial): Promise { + this.logger.log('update', updates, conditions); + + return this.target.update(updates, conditions); + } + + /** + * @inheritdoc + */ + updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + this.logger.log('updateWhere', updates, conditions); + + return this.target.updateWhere(updates, conditions); + } + + /** + * @inheritdoc + */ + delete(conditions?: Partial): Promise { + this.logger.log('delete', conditions); + + return this.target.delete(conditions); + } + + /** + * @inheritdoc + */ + deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + this.logger.log('deleteByPrimaryKey', primaryKey); + + return this.target.deleteByPrimaryKey(primaryKey); + } + +} diff --git a/src/core/classes/database/eager-database-table.ts b/src/core/classes/database/eager-database-table.ts new file mode 100644 index 000000000..37b09342c --- /dev/null +++ b/src/core/classes/database/eager-database-table.ts @@ -0,0 +1,168 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreError } from '@classes/errors/error'; +import { SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey, CoreDatabaseReducer } from './database-table'; + +/** + * Wrapper used to improve performance by caching all the records for faster read operations. + * + * This implementation works best for tables that don't have a lot of records and are read often; for tables with too many + * records use CoreLazyDatabaseTable instead. + */ +export class CoreEagerDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> extends CoreDatabaseTable { + + protected records: Record = {}; + + /** + * @inheritdoc + */ + async initialize(): Promise { + const records = await super.getMany(); + + this.records = records.reduce((data, record) => { + const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record)); + + data[primaryKey] = record; + + return data; + }, {}); + } + + /** + * @inheritdoc + */ + async getMany(conditions?: Partial): Promise { + const records = Object.values(this.records); + + return conditions + ? records.filter(record => this.recordMatches(record, conditions)) + : records; + } + + /** + * @inheritdoc + */ + async getOne(conditions: Partial): Promise { + const record = Object.values(this.records).find(record => this.recordMatches(record, conditions)) ?? null; + + if (record === null) { + throw new CoreError('No records found.'); + } + + return record; + } + + /** + * @inheritdoc + */ + async getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { + const record = this.records[this.serializePrimaryKey(primaryKey)] ?? null; + + if (record === null) { + throw new CoreError('No records found.'); + } + + return record; + } + + /** + * @inheritdoc + */ + async reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { + return Object + .values(this.records) + .reduce( + (result, record) => (!conditions || conditions.js(record)) ? reducer.js(result, record) : result, + reducer.jsInitialValue, + ); + } + + /** + * @inheritdoc + */ + async insert(record: DBRecord): Promise { + await super.insert(record); + + const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record)); + + this.records[primaryKey] = record; + } + + /** + * @inheritdoc + */ + async update(updates: Partial, conditions?: Partial): Promise { + await super.update(updates, conditions); + + for (const record of Object.values(this.records)) { + if (conditions && !this.recordMatches(record, conditions)) { + continue; + } + + Object.assign(record, updates); + } + } + + /** + * @inheritdoc + */ + async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + await super.updateWhere(updates, conditions); + + for (const record of Object.values(this.records)) { + if (!conditions.js(record)) { + continue; + } + + Object.assign(record, updates); + } + } + + /** + * @inheritdoc + */ + async delete(conditions?: Partial): Promise { + await super.delete(conditions); + + if (!conditions) { + this.records = {}; + + return; + } + + Object.entries(this.records).forEach(([id, record]) => { + if (!this.recordMatches(record, conditions)) { + return; + } + + delete this.records[id]; + }); + } + + /** + * @inheritdoc + */ + async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + await super.deleteByPrimaryKey(primaryKey); + + delete this.records[this.serializePrimaryKey(primaryKey)]; + } + +} diff --git a/src/core/classes/database/lazy-database-table.ts b/src/core/classes/database/lazy-database-table.ts new file mode 100644 index 000000000..9ec890a20 --- /dev/null +++ b/src/core/classes/database/lazy-database-table.ts @@ -0,0 +1,141 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreError } from '@classes/errors/error'; +import { SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey } from './database-table'; + +/** + * Wrapper used to improve performance by caching records that are used often for faster read operations. + * + * This implementation works best for tables that have a lot of records and are read often; for tables with a few records use + * CoreEagerDatabaseTable instead. + */ +export class CoreLazyDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> extends CoreDatabaseTable { + + protected records: Record = {}; + + /** + * @inheritdoc + */ + async getOne(conditions: Partial): Promise { + let record: DBRecord | null = + Object.values(this.records).find(record => record && this.recordMatches(record, conditions)) ?? null; + + if (!record) { + record = await super.getOne(conditions); + + this.records[this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record))] = record; + } + + return record; + } + + /** + * @inheritdoc + */ + async getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { + const serializePrimaryKey = this.serializePrimaryKey(primaryKey); + + if (!(serializePrimaryKey in this.records)) { + try { + const record = await super.getOneByPrimaryKey(primaryKey); + + this.records[serializePrimaryKey] = record; + + return record; + } catch (error) { + this.records[serializePrimaryKey] = null; + + throw error; + } + } + + const record = this.records[serializePrimaryKey]; + + if (!record) { + throw new CoreError('No records found.'); + } + + return record; + } + + /** + * @inheritdoc + */ + async insert(record: DBRecord): Promise { + await super.insert(record); + + this.records[this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record))] = record; + } + + /** + * @inheritdoc + */ + async update(updates: Partial, conditions?: Partial): Promise { + await super.update(updates, conditions); + + for (const record of Object.values(this.records)) { + if (!record || (conditions && !this.recordMatches(record, conditions))) { + continue; + } + + Object.assign(record, updates); + } + } + + /** + * @inheritdoc + */ + async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + await super.updateWhere(updates, conditions); + + for (const record of Object.values(this.records)) { + if (!record || !conditions.js(record)) { + continue; + } + + Object.assign(record, updates); + } + } + + /** + * @inheritdoc + */ + async delete(conditions?: Partial): Promise { + await super.delete(conditions); + + for (const [primaryKey, record] of Object.entries(this.records)) { + if (!record || (conditions && !this.recordMatches(record, conditions))) { + continue; + } + + this.records[primaryKey] = null; + } + } + + /** + * @inheritdoc + */ + async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + await super.deleteByPrimaryKey(primaryKey); + + this.records[this.serializePrimaryKey(primaryKey)] = null; + } + +} diff --git a/src/core/classes/promised-value.ts b/src/core/classes/promised-value.ts index ae7bbbdec..16051be37 100644 --- a/src/core/classes/promised-value.ts +++ b/src/core/classes/promised-value.ts @@ -134,6 +134,16 @@ export class CorePromisedValue implements Promise { this._reject(reason); } + /** + * Reset status and value. + */ + reset(): void { + delete this._resolvedValue; + delete this._rejectedReason; + + this.initPromise(); + } + /** * Initialize the promise and the callbacks. */ diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index dc702baf9..d95590192 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -41,6 +41,10 @@ import { CoreLogger } from '@singletons/logger'; import { Translate } from '@singletons'; import { CoreIonLoadingElement } from './ion-loading'; import { CoreLang } from '@services/lang'; +import { CoreSites } from '@services/sites'; +import { asyncInstance, AsyncInstance } from '../utils/async-instance'; +import { CoreDatabaseTable } from './database/database-table'; +import { CoreDatabaseCachingStrategy } from './database/database-table-proxy'; /** * QR Code type enumeration. @@ -103,6 +107,7 @@ export class CoreSite { // Rest of variables. protected logger: CoreLogger; protected db?: SQLiteDB; + protected cacheTable: AsyncInstance>; protected cleanUnicode = false; protected lastAutoLogin = 0; protected offlineDisabled = false; @@ -136,6 +141,11 @@ export class CoreSite { ) { this.logger = CoreLogger.getInstance('CoreSite'); this.siteUrl = CoreUrlUtils.removeUrlParams(this.siteUrl); // Make sure the URL doesn't have params. + this.cacheTable = asyncInstance(() => CoreSites.getSiteTable(CoreSite.WS_CACHE_TABLE, { + siteId: this.getId(), + database: this.getDb(), + config: { cachingStrategy: CoreDatabaseCachingStrategy.None }, + })); this.setInfo(infos); this.calculateOfflineDisabled(); @@ -920,8 +930,7 @@ export class CoreSite { preSets: CoreSiteWSPreSets, emergency?: boolean, ): Promise { - const db = this.db; - if (!db || !preSets.getFromCache) { + if (!this.db || !preSets.getFromCache) { throw new CoreError('Get from cache is disabled.'); } @@ -929,11 +938,11 @@ export class CoreSite { let entry: CoreSiteWSCacheRecord | undefined; if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { - const entries = await db.getRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }); + const entries = await this.cacheTable.getMany({ key: preSets.cacheKey }); if (!entries.length) { // Cache key not found, get by params sent. - entry = await db.getRecord(CoreSite.WS_CACHE_TABLE, { id }); + entry = await this.cacheTable.getOneByPrimaryKey({ id }); } else { if (entries.length > 1) { // More than one entry found. Search the one with same ID as this call. @@ -945,7 +954,7 @@ export class CoreSite { } } } else { - entry = await db.getRecord(CoreSite.WS_CACHE_TABLE, { id }); + entry = await this.cacheTable.getOneByPrimaryKey({ id }); } if (entry === undefined) { @@ -996,12 +1005,18 @@ export class CoreSite { extraClause = ' AND componentId = ?'; } - const size = await this.getDb().getFieldSql( - 'SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE + ' WHERE component = ?' + extraClause, - params, + return this.cacheTable.reduce( + { + sql: 'SUM(length(data))', + js: (size, record) => size + record.data.length, + jsInitialValue: 0, + }, + { + sql: 'WHERE component = ?' + extraClause, + sqlParams: params, + js: record => record.component === component && (params.length === 1 || record.componentId === componentId), + }, ); - - return size; } /** @@ -1015,10 +1030,6 @@ export class CoreSite { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async saveToCache(method: string, data: any, response: any, preSets: CoreSiteWSPreSets): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized.'); - } - if (preSets.uniqueCacheKey) { // Cache key must be unique, delete all entries with same cache key. await CoreUtils.ignoreErrors(this.deleteFromCache(method, data, preSets, true)); @@ -1044,7 +1055,7 @@ export class CoreSite { } } - await this.db.insertRecord(CoreSite.WS_CACHE_TABLE, entry); + await this.cacheTable.insert(entry); } /** @@ -1058,16 +1069,12 @@ export class CoreSite { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async deleteFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, allCacheKey?: boolean): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized.'); - } - const id = this.getCacheId(method, data); if (allCacheKey) { - await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }); + await this.cacheTable.delete({ key: preSets.cacheKey }); } else { - await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, { id }); + await this.cacheTable.deleteByPrimaryKey({ id }); } } @@ -1084,18 +1091,13 @@ export class CoreSite { return; } - if (!this.db) { - throw new CoreError('Site DB not initialized'); - } + const params = { component }; - const params = { - component, - }; if (componentId) { params['componentId'] = componentId; } - await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, params); + await this.cacheTable.delete(params); } /* @@ -1127,14 +1129,10 @@ export class CoreSite { * @return Promise resolved when the cache entries are invalidated. */ async invalidateWsCache(): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized'); - } - this.logger.debug('Invalidate all the cache for site: ' + this.id); try { - await this.db.updateRecords(CoreSite.WS_CACHE_TABLE, { expirationTime: 0 }); + await this.cacheTable.update({ expirationTime: 0 }); } finally { CoreEvents.trigger(CoreEvents.WS_CACHE_INVALIDATED, {}, this.getId()); } @@ -1147,16 +1145,13 @@ export class CoreSite { * @return Promise resolved when the cache entries are invalidated. */ async invalidateWsCacheForKey(key: string): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized'); - } if (!key) { return; } this.logger.debug('Invalidate cache for key: ' + key); - await this.db.updateRecords(CoreSite.WS_CACHE_TABLE, { expirationTime: 0 }, { key }); + await this.cacheTable.update({ expirationTime: 0 }, { key }); } /** @@ -1184,18 +1179,17 @@ export class CoreSite { * @return Promise resolved when the cache entries are invalidated. */ async invalidateWsCacheForKeyStartingWith(key: string): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized'); - } if (!key) { return; } this.logger.debug('Invalidate cache for key starting with: ' + key); - const sql = 'UPDATE ' + CoreSite.WS_CACHE_TABLE + ' SET expirationTime=0 WHERE key LIKE ?'; - - await this.db.execute(sql, [key + '%']); + await this.cacheTable.updateWhere({ expirationTime: 0 }, { + sql: 'key LIKE ?', + sqlParams: [key], + js: record => !!record.key?.startsWith(key), + }); } /** @@ -1270,9 +1264,11 @@ export class CoreSite { * @return Promise resolved with the total size of all data in the cache table (bytes) */ async getCacheUsage(): Promise { - const size = await this.getDb().getFieldSql('SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE); - - return size; + return this.cacheTable.reduce({ + sql: 'SUM(length(data))', + js: (size, record) => size + record.data.length, + jsInitialValue: 0, + }); } /** diff --git a/src/core/classes/sqlitedb.ts b/src/core/classes/sqlitedb.ts index 615a3eb7f..915ea9ffb 100644 --- a/src/core/classes/sqlitedb.ts +++ b/src/core/classes/sqlitedb.ts @@ -1206,4 +1206,4 @@ export type SQLiteDBQueryParams = { params: SQLiteDBRecordValue[]; }; -type SQLiteDBRecordValue = number | string; +export type SQLiteDBRecordValue = number | string; diff --git a/src/core/classes/tests/database-table.test.ts b/src/core/classes/tests/database-table.test.ts index 92e6d7ec2..9c7f1757f 100644 --- a/src/core/classes/tests/database-table.test.ts +++ b/src/core/classes/tests/database-table.test.ts @@ -13,7 +13,12 @@ // limitations under the License. import { mock } from '@/testing/utils'; -import { CoreDatabaseTable } from '@classes/database-table'; +import { CoreDatabaseTable } from '@classes/database/database-table'; +import { + CoreDatabaseCachingStrategy, + CoreDatabaseConfiguration, + CoreDatabaseTableProxy, +} from '@classes/database/database-table-proxy'; import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; interface User extends SQLiteDBRecordValues { @@ -22,103 +27,197 @@ interface User extends SQLiteDBRecordValues { surname: string; } -class UsersTable extends CoreDatabaseTable { - - protected table = 'users'; - +function userMatches(user: User, conditions: Partial) { + return !Object.entries(conditions).some(([column, value]) => user[column] !== value); } -describe('CoreDatabaseTable', () => { +function prepareStubs(config: Partial = {}): [User[], SQLiteDB, CoreDatabaseTable] { + const records: User[] = []; + const database = mock({ + getRecord: async (_, conditions) => { + const record = records.find(record => userMatches(record, conditions)); + + if (!record) { + throw new Error(); + } + + return record as unknown as T; + }, + getRecords: async (_, conditions) => records.filter(record => userMatches(record, conditions)) as unknown as T[], + getAllRecords: async () => records as unknown as T[], + deleteRecords: async (_, conditions) => { + const usersToDelete: User[] = []; + + for (const user of records) { + if (conditions && !userMatches(user, conditions)) { + continue; + } + + usersToDelete.push(user); + } + + for (const user of usersToDelete) { + records.splice(records.indexOf(user), 1); + } + + return usersToDelete.length; + }, + insertRecord: async (_, user: User) => records.push(user) && 1, + }); + const table = new CoreDatabaseTableProxy(config, database, 'users'); + + return [records, database, table]; +} + +async function testFindItems(records: User[], table: CoreDatabaseTable) { + const john = { id: 1, name: 'John', surname: 'Doe' }; + const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + + records.push(john); + records.push(amy); + + await table.initialize(); + + await expect(table.getOneByPrimaryKey({ id: 1 })).resolves.toEqual(john); + await expect(table.getOneByPrimaryKey({ id: 2 })).resolves.toEqual(amy); + await expect(table.getOne({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); + await expect(table.getOne({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); +} + +async function testInsertItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + + await table.initialize(); + + // Act. + await table.insert(john); + + // Assert. + expect(database.insertRecord).toHaveBeenCalledWith('users', john); + + await expect(table.getOneByPrimaryKey({ id: 1 })).resolves.toEqual(john); +} + +async function testDeleteItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + const jane = { id: 3, name: 'Jane', surname: 'Smith' }; + + records.push(john); + records.push(amy); + records.push(jane); + + await table.initialize(); + + // Act. + await table.delete({ surname: 'Doe' }); + + // Assert. + expect(database.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); + + await expect(table.getOneByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.getOneByPrimaryKey({ id: 2 })).rejects.toThrow(); + await expect(table.getOneByPrimaryKey({ id: 3 })).resolves.toEqual(jane); +} + +async function testDeleteItemsByPrimaryKey(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + + records.push(john); + records.push(amy); + + await table.initialize(); + + // Act. + await table.deleteByPrimaryKey({ id: 1 }); + + // Assert. + expect(database.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); + + await expect(table.getOneByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.getOneByPrimaryKey({ id: 2 })).resolves.toEqual(amy); +} + +describe('CoreDatabaseTable with eager caching', () => { let records: User[]; - let db: SQLiteDB; + let database: SQLiteDB; + let table: CoreDatabaseTable; - beforeEach(() => { - records = []; - db = mock({ - getRecords: async () => records as unknown as T[], - deleteRecords: async () => 0, - insertRecord: async () => 0, - }); - }); + beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Eager })); - it('reads all records on create', async () => { - await UsersTable.create(db); + it('reads all records on initialization', async () => { + await table.initialize(); - expect(db.getRecords).toHaveBeenCalledWith('users'); + expect(database.getAllRecords).toHaveBeenCalledWith('users'); }); it('finds items', async () => { - const john = { id: 1, name: 'John', surname: 'Doe' }; - const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + await testFindItems(records, table); - records.push(john); - records.push(amy); - - const table = await UsersTable.create(db); - - expect(table.findByPrimaryKey({ id: 1 })).toEqual(john); - expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy); - expect(table.find({ surname: 'Doe', name: 'John' })).toEqual(john); - expect(table.find({ surname: 'Doe', name: 'Amy' })).toEqual(amy); + expect(database.getRecord).not.toHaveBeenCalled(); }); - it('inserts items', async () => { - // Arrange. - const john = { id: 1, name: 'John', surname: 'Doe' }; - - // Act. - const table = await UsersTable.create(db); - - await table.insert(john); - - // Assert. - expect(db.insertRecord).toHaveBeenCalledWith('users', john); - - expect(table.findByPrimaryKey({ id: 1 })).toEqual(john); - }); - - it('deletes items', async () => { - // Arrange. - const john = { id: 1, name: 'John', surname: 'Doe' }; - const amy = { id: 2, name: 'Amy', surname: 'Doe' }; - const jane = { id: 3, name: 'Jane', surname: 'Smith' }; - - records.push(john); - records.push(amy); - records.push(jane); - - // Act. - const table = await UsersTable.create(db); - - await table.delete({ surname: 'Doe' }); - - // Assert. - expect(db.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); - - expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); - expect(table.findByPrimaryKey({ id: 2 })).toBeNull(); - expect(table.findByPrimaryKey({ id: 3 })).toEqual(jane); - }); - - it('deletes items by primary key', async () => { - // Arrange. - const john = { id: 1, name: 'John', surname: 'Doe' }; - const amy = { id: 2, name: 'Amy', surname: 'Doe' }; - - records.push(john); - records.push(amy); - - // Act. - const table = await UsersTable.create(db); - - await table.deleteByPrimaryKey({ id: 1 }); - - // Assert. - expect(db.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); - - expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); - expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy); - }); + it('inserts items', () => testInsertItems(records, database, table)); + it('deletes items', () => testDeleteItems(records, database, table)); + it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); + +}); + +describe('CoreDatabaseTable with lazy caching', () => { + + let records: User[]; + let database: SQLiteDB; + let table: CoreDatabaseTable; + + beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Lazy })); + + it('reads no records on initialization', async () => { + await table.initialize(); + + expect(database.getRecords).not.toHaveBeenCalled(); + expect(database.getAllRecords).not.toHaveBeenCalled(); + }); + + it('finds items', async () => { + await testFindItems(records, table); + + expect(database.getRecord).toHaveBeenCalledTimes(2); + }); + + it('inserts items', () => testInsertItems(records, database, table)); + it('deletes items', () => testDeleteItems(records, database, table)); + it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); + +}); + +describe('CoreDatabaseTable with no caching', () => { + + let records: User[]; + let database: SQLiteDB; + let table: CoreDatabaseTable; + + beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.None })); + + it('reads no records on initialization', async () => { + await table.initialize(); + + expect(database.getRecords).not.toHaveBeenCalled(); + expect(database.getAllRecords).not.toHaveBeenCalled(); + }); + + it('finds items', async () => { + await testFindItems(records, table); + + expect(database.getRecord).toHaveBeenCalledTimes(4); + }); + + it('inserts items', () => testInsertItems(records, database, table)); + it('deletes items', () => testDeleteItems(records, database, table)); + it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); }); diff --git a/src/core/constants.ts b/src/core/constants.ts index 2c238261b..b83e896a8 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -145,23 +145,6 @@ export class CoreConstants { static readonly CONFIG = { ...envJson.config } as unknown as EnvironmentConfig; // Data parsed from config.json files. static readonly BUILD = envJson.build as unknown as EnvironmentBuild; // Build info. - /** - * Update config with the given values. - * - * @param config Config updates. - */ - static patchConfig(config: Partial): void { - Object.assign(this.CONFIG, config); - } - - /** - * Reset config values to its original state. - */ - static resetConfig(): void { - Object.keys(this.CONFIG).forEach(key => delete this.CONFIG[key]); - Object.assign(this.CONFIG, envJson.config); - } - } interface EnvironmentBuild { diff --git a/src/core/initializers/initialize-services.ts b/src/core/initializers/initialize-services.ts index bef39b3a1..6b172b93c 100644 --- a/src/core/initializers/initialize-services.ts +++ b/src/core/initializers/initialize-services.ts @@ -15,11 +15,13 @@ import { CoreFilepool } from '@services/filepool'; import { CoreLang } from '@services/lang'; import { CoreLocalNotifications } from '@services/local-notifications'; +import { CoreSites } from '@services/sites'; import { CoreUpdateManager } from '@services/update-manager'; export default async function(): Promise { await Promise.all([ CoreFilepool.initialize(), + CoreSites.initialize(), CoreLang.initialize(), CoreLocalNotifications.initialize(), CoreUpdateManager.initialize(), diff --git a/src/core/services/config.ts b/src/core/services/config.ts index ee49af16d..ef0f65d5a 100644 --- a/src/core/services/config.ts +++ b/src/core/services/config.ts @@ -12,13 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { EnvironmentConfig } from '@/types/config'; import { Injectable } from '@angular/core'; - +import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; import { CoreApp } from '@services/app'; +import { APP_SCHEMA, ConfigDBEntry, CONFIG_TABLE_NAME } from '@services/database/config'; import { makeSingleton } from '@singletons'; -import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/database/config'; -import { CoreDatabaseTable } from '@classes/database-table'; -import { CorePromisedValue } from '@classes/promised-value'; +import { CoreConstants } from '../constants'; +import { CoreEvents } from '@singletons/events'; +import { CoreDatabaseTable } from '@classes/database/database-table'; +import { asyncInstance } from '../utils/async-instance'; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [CoreConfigProvider.ENVIRONMENT_UPDATED]: EnvironmentConfig; + } + +} /** * Factory to provide access to dynamic and permanent config and settings. @@ -27,11 +43,10 @@ import { CorePromisedValue } from '@classes/promised-value'; @Injectable({ providedIn: 'root' }) export class CoreConfigProvider { - protected dbTable: CorePromisedValue; + static readonly ENVIRONMENT_UPDATED = 'environment_updated'; - constructor() { - this.dbTable = new CorePromisedValue(); - } + protected table = asyncInstance>(); + protected defaultEnvironment?: EnvironmentConfig; /** * Initialize database. @@ -43,10 +58,16 @@ export class CoreConfigProvider { // Ignore errors. } - const db = CoreApp.getDB(); - const table = await CoreConfigTable.create(db); + const table = new CoreDatabaseTableProxy( + { cachingStrategy: CoreDatabaseCachingStrategy.Eager }, + CoreApp.getDB(), + CONFIG_TABLE_NAME, + ['name'], + ); - this.dbTable.resolve(table); + await table.initialize(); + + this.table.setInstance(table); } /** @@ -56,9 +77,7 @@ export class CoreConfigProvider { * @return Promise resolved when done. */ async delete(name: string): Promise { - const table = await this.dbTable; - - await table.deleteByPrimaryKey({ name }); + await this.table.deleteByPrimaryKey({ name }); } /** @@ -69,18 +88,17 @@ export class CoreConfigProvider { * @return Resolves upon success along with the config data. Reject on failure. */ async get(name: string, defaultValue?: T): Promise { - const table = await this.dbTable; - const record = table.findByPrimaryKey({ name }); + try { + const record = await this.table.getOneByPrimaryKey({ name }); - if (record !== null) { return record.value; - } + } catch (error) { + if (defaultValue !== undefined) { + return defaultValue; + } - if (defaultValue !== undefined) { - return defaultValue; + throw error; } - - throw new Error(`Couldn't get config with name '${name}'`); } /** @@ -91,21 +109,36 @@ export class CoreConfigProvider { * @return Promise resolved when done. */ async set(name: string, value: number | string): Promise { - const table = await this.dbTable; + await this.table.insert({ name, value }); + } - await table.insert({ name, value }); + /** + * Update config with the given values. + * + * @param config Config updates. + */ + patchEnvironment(config: Partial): void { + this.defaultEnvironment = this.defaultEnvironment ?? CoreConstants.CONFIG; + + Object.assign(CoreConstants.CONFIG, config); + CoreEvents.trigger(CoreConfigProvider.ENVIRONMENT_UPDATED, CoreConstants.CONFIG); + } + + /** + * Reset config values to its original state. + */ + resetEnvironment(): void { + if (!this.defaultEnvironment) { + // The environment config hasn't been modified; there's not need to reset. + + return; + } + + Object.keys(CoreConstants.CONFIG).forEach(key => delete CoreConstants.CONFIG[key]); + Object.assign(CoreConstants.CONFIG, this.defaultEnvironment); + CoreEvents.trigger(CoreConfigProvider.ENVIRONMENT_UPDATED, CoreConstants.CONFIG); } } export const CoreConfig = makeSingleton(CoreConfigProvider); - -/** - * Config database table. - */ -class CoreConfigTable extends CoreDatabaseTable { - - protected table = CONFIG_TABLE_NAME; - protected primaryKeys = ['name']; - -} diff --git a/src/core/services/db.ts b/src/core/services/db.ts index 727b5424d..489d44072 100644 --- a/src/core/services/db.ts +++ b/src/core/services/db.ts @@ -38,6 +38,17 @@ export class CoreDbProvider { return CoreAppProvider.isAutomated(); } + /** + * Print query history in console. + */ + printHistory(): void { + const substituteParams = ({ sql, params }: CoreDbQueryLog) => + Object.values(params ?? []).reduce((sql: string, param: string) => sql.replace('?', param), sql); + + // eslint-disable-next-line no-console + console.log(this.queryLogs.map(substituteParams).join('\n')); + } + /** * Log a query. * diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index 40c2368a6..06b54fb61 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -48,6 +48,10 @@ import { } from '@services/database/filepool'; import { CoreFileHelper } from './file-helper'; import { CoreUrl } from '@singletons/url'; +import { CoreDatabaseTable } from '@classes/database/database-table'; +import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy'; +import { lazyMap, LazyMap } from '../utils/lazy-map'; +import { asyncInstance, AsyncInstance } from '../utils/async-instance'; /* * Factory for handling downloading files and retrieve downloaded files. @@ -72,9 +76,13 @@ export class CoreFilepoolProvider { protected static readonly ERR_FS_OR_NETWORK_UNAVAILABLE = 'CoreFilepoolError:ERR_FS_OR_NETWORK_UNAVAILABLE'; protected static readonly ERR_QUEUE_ON_PAUSE = 'CoreFilepoolError:ERR_QUEUE_ON_PAUSE'; - protected static readonly FILE_UPDATE_UNKNOWN_WHERE_CLAUSE = + protected static readonly FILE_IS_UNKNOWN_SQL = 'isexternalfile = 1 OR ((revision IS NULL OR revision = 0) AND (timemodified IS NULL OR timemodified = 0))'; + protected static readonly FILE_IS_UNKNOWN_JS = + ({ isexternalfile, revision, timemodified }: CoreFilepoolFileEntry): boolean => + isexternalfile === 1 || ((revision === null || revision === 0) && (timemodified === null || timemodified === 0)); + protected logger: CoreLogger; protected queueState = CoreFilepoolProvider.QUEUE_PAUSED; protected urlAttributes: RegExp[] = [ @@ -94,10 +102,20 @@ export class CoreFilepoolProvider { // Variables for DB. protected appDB: Promise; protected resolveAppDB!: (appDB: SQLiteDB) => void; + protected filesTables: LazyMap>>; constructor() { this.appDB = new Promise(resolve => this.resolveAppDB = resolve); this.logger = CoreLogger.getInstance('CoreFilepoolProvider'); + this.filesTables = lazyMap( + siteId => asyncInstance( + () => CoreSites.getSiteTable(FILES_TABLE_NAME, { + siteId, + config: { cachingStrategy: CoreDatabaseCachingStrategy.Lazy }, + primaryKeyColumns: ['fileId'], + }), + ), + ); } /** @@ -114,6 +132,16 @@ export class CoreFilepoolProvider { NgZone.run(() => this.checkQueueProcessing()); }); }); + + CoreEvents.on(CoreEvents.SITE_DELETED, async ({ siteId }) => { + if (!siteId || !(siteId in this.filesTables)) { + return; + } + + await this.filesTables[siteId].destroy(); + + delete this.filesTables[siteId]; + }); } /** @@ -215,9 +243,7 @@ export class CoreFilepoolProvider { ...data, }; - const db = await CoreSites.getSiteDb(siteId); - - await db.insertRecord(FILES_TABLE_NAME, record); + await this.filesTables[siteId].insert(record); } /** @@ -560,11 +586,11 @@ export class CoreFilepoolProvider { const db = await CoreSites.getSiteDb(siteId); // Read the data first to be able to notify the deletions. - const filesEntries = await db.getAllRecords(FILES_TABLE_NAME); + const filesEntries = await this.filesTables[siteId].getMany(); const filesLinks = await db.getAllRecords(LINKS_TABLE_NAME); await Promise.all([ - db.deleteRecords(FILES_TABLE_NAME), + this.filesTables[siteId].delete(), db.deleteRecords(LINKS_TABLE_NAME), ]); @@ -1125,7 +1151,7 @@ export class CoreFilepoolProvider { // Minor problem: file will remain in the filesystem once downloaded again. this.logger.debug('Staled file with no extension ' + entry.fileId); - await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId: entry.fileId }); + await this.filesTables[siteId].update({ stale: 1 }, { fileId: entry.fileId }); return; } @@ -1135,7 +1161,7 @@ export class CoreFilepoolProvider { entry.fileId = CoreMimetypeUtils.removeExtension(fileId); entry.extension = extension; - await db.updateRecords(FILES_TABLE_NAME, entry, { fileId }); + await this.filesTables[siteId].update(entry, { fileId }); if (entry.fileId == fileId) { // File ID hasn't changed, we're done. this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId); @@ -1401,10 +1427,7 @@ export class CoreFilepoolProvider { await Promise.all(items.map(async (item) => { try { - const fileEntry = await db.getRecord( - FILES_TABLE_NAME, - { fileId: item.fileId }, - ); + const fileEntry = await this.filesTables[siteId].getOneByPrimaryKey({ fileId: item.fileId }); if (!fileEntry) { return; @@ -2137,14 +2160,7 @@ export class CoreFilepoolProvider { * @return Resolved with file object from DB on success, rejected otherwise. */ protected async hasFileInPool(siteId: string, fileId: string): Promise { - const db = await CoreSites.getSiteDb(siteId); - const entry = await db.getRecord(FILES_TABLE_NAME, { fileId }); - - if (entry === undefined) { - throw new CoreError('File not found in filepool.'); - } - - return entry; + return this.filesTables[siteId].getOneByPrimaryKey({ fileId }); } /** @@ -2176,11 +2192,15 @@ export class CoreFilepoolProvider { * @return Resolved on success. */ async invalidateAllFiles(siteId: string, onlyUnknown: boolean = true): Promise { - const db = await CoreSites.getSiteDb(siteId); - - const where = onlyUnknown ? CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE : undefined; - - await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, where); + onlyUnknown + ? await this.filesTables[siteId].updateWhere( + { stale: 1 }, + { + sql: CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL, + js: CoreFilepoolProvider.FILE_IS_UNKNOWN_JS, + }, + ) + : await this.filesTables[siteId].update({ stale: 1 }); } /** @@ -2199,9 +2219,7 @@ export class CoreFilepoolProvider { const file = await this.fixPluginfileURL(siteId, fileUrl); const fileId = this.getFileIdByUrl(CoreFileHelper.getFileUrl(file)); - const db = await CoreSites.getSiteDb(siteId); - - await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId }); + await this.filesTables[siteId].update({ stale: 1 }, { fileId }); } /** @@ -2221,7 +2239,6 @@ export class CoreFilepoolProvider { onlyUnknown: boolean = true, ): Promise { const db = await CoreSites.getSiteDb(siteId); - const items = await this.getComponentFiles(db, component, componentId); if (!items.length) { @@ -2229,6 +2246,8 @@ export class CoreFilepoolProvider { return; } + siteId = siteId ?? CoreSites.getCurrentSiteId(); + const fileIds = items.map((item) => item.fileId); const whereAndParams = db.getInOrEqual(fileIds); @@ -2236,10 +2255,19 @@ export class CoreFilepoolProvider { whereAndParams.sql = 'fileId ' + whereAndParams.sql; if (onlyUnknown) { - whereAndParams.sql += ' AND (' + CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE + ')'; + whereAndParams.sql += ' AND (' + CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL + ')'; } - await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, whereAndParams.sql, whereAndParams.params); + await this.filesTables[siteId].updateWhere( + { stale: 1 }, + { + sql: whereAndParams.sql, + sqlParams: whereAndParams.params, + js: record => fileIds.includes(record.fileId) && ( + !onlyUnknown || CoreFilepoolProvider.FILE_IS_UNKNOWN_JS(record) + ), + }, + ); } /** @@ -2657,6 +2685,7 @@ export class CoreFilepoolProvider { */ protected async removeFileById(siteId: string, fileId: string): Promise { const db = await CoreSites.getSiteDb(siteId); + // Get the path to the file first since it relies on the file object stored in the pool. // Don't use getFilePath to prevent performing 2 DB requests. let path = this.getFilepoolFolderPath(siteId) + '/' + fileId; @@ -2682,7 +2711,7 @@ export class CoreFilepoolProvider { const promises: Promise[] = []; // Remove entry from filepool store. - promises.push(db.deleteRecords(FILES_TABLE_NAME, conditions)); + promises.push(this.filesTables[siteId].delete(conditions)); // Remove links. promises.push(db.deleteRecords(LINKS_TABLE_NAME, conditions)); diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index c20644f22..ee324ae59 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -32,7 +32,7 @@ import { CoreSitePublicConfigResponse, CoreSiteInfoResponse, } from '@classes/site'; -import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; +import { SQLiteDB, SQLiteDBRecordValues, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { CoreError } from '@classes/errors/error'; import { CoreSiteError } from '@classes/errors/siteerror'; import { makeSingleton, Translate, Http } from '@singletons'; @@ -57,6 +57,9 @@ import { CoreErrorWithTitle } from '@classes/errors/errorwithtitle'; import { CoreAjaxError } from '@classes/errors/ajaxerror'; import { CoreAjaxWSError } from '@classes/errors/ajaxwserror'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreDatabaseTable } from '@classes/database/database-table'; +import { CoreDatabaseConfiguration, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; export const CORE_SITE_SCHEMAS = new InjectionToken('CORE_SITE_SCHEMAS'); @@ -85,6 +88,7 @@ export class CoreSitesProvider { // Variables for DB. protected appDB: Promise; protected resolveAppDB!: (appDB: SQLiteDB) => void; + protected siteTables: Record>> = {}; constructor(@Optional() @Inject(CORE_SITE_SCHEMAS) siteSchemas: CoreSiteSchema[][] = []) { this.appDB = new Promise(resolve => this.resolveAppDB = resolve); @@ -99,6 +103,25 @@ export class CoreSitesProvider { ); } + /** + * Initialize. + */ + initialize(): void { + CoreEvents.on(CoreEvents.SITE_DELETED, async ({ siteId }) => { + if (!siteId || !(siteId in this.siteTables)) { + return; + } + + await Promise.all( + Object + .values(this.siteTables[siteId]) + .map(promisedTable => promisedTable.then(table => table.destroy())), + ); + + delete this.siteTables[siteId]; + }); + } + /** * Initialize database. */ @@ -112,6 +135,49 @@ export class CoreSitesProvider { this.resolveAppDB(CoreApp.getDB()); } + /** + * Get site table. + * + * @param tableName Site table name. + * @param options Options to configure table initialization. + * @returns Site table. + */ + async getSiteTable< + DBRecord extends SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord + >( + tableName: string, + options: Partial<{ + siteId: string; + config: Partial; + database: SQLiteDB; + primaryKeyColumns: PrimaryKeyColumn[]; + }> = {}, + ): Promise> { + const siteId = options.siteId ?? this.getCurrentSiteId(); + + if (!(siteId in this.siteTables)) { + this.siteTables[siteId] = {}; + } + + if (!(tableName in this.siteTables[siteId])) { + const promisedTable = this.siteTables[siteId][tableName] = new CorePromisedValue(); + const database = options.database ?? await this.getSiteDb(siteId); + const table = new CoreDatabaseTableProxy( + options.config ?? {}, + database, + tableName, + options.primaryKeyColumns, + ); + + await table.initialize(); + + promisedTable.resolve(table as unknown as CoreDatabaseTable); + } + + return this.siteTables[siteId][tableName] as unknown as Promise>; + } + /** * Get the demo data for a certain "name" if it is a demo site. * diff --git a/src/core/utils/async-instance.ts b/src/core/utils/async-instance.ts new file mode 100644 index 000000000..f687e8a70 --- /dev/null +++ b/src/core/utils/async-instance.ts @@ -0,0 +1,122 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CorePromisedValue } from '@classes/promised-value'; + +/** + * Create a wrapper to hold an asynchronous instance. + * + * @param lazyConstructor Constructor to use the first time the instance is needed. + * @returns Asynchronous instance wrapper. + */ +function createAsyncInstanceWrapper(lazyConstructor?: () => T | Promise): AsyncInstanceWrapper { + let promisedInstance: CorePromisedValue | null = null; + + return { + get instance() { + return promisedInstance?.value ?? undefined; + }, + async getInstance() { + if (!promisedInstance) { + promisedInstance = new CorePromisedValue(); + + if (lazyConstructor) { + const instance = await lazyConstructor(); + + promisedInstance.resolve(instance); + } + } + + return promisedInstance; + }, + async getProperty(property) { + const instance = await this.getInstance(); + + return instance[property]; + }, + setInstance(instance) { + if (!promisedInstance) { + promisedInstance = new CorePromisedValue(); + } else if (promisedInstance.isSettled()) { + promisedInstance.reset(); + } + + promisedInstance.resolve(instance); + }, + resetInstance() { + if (!promisedInstance) { + return; + } + + promisedInstance.reset(); + }, + }; +} + +/** + * Asynchronous instance wrapper. + */ +export interface AsyncInstanceWrapper { + instance?: T; + getInstance(): Promise; + getProperty

(property: P): Promise; + setInstance(instance: T): void; + resetInstance(): void; +} + +/** + * Asynchronous version of a method. + */ +export type AsyncMethod = + T extends (...args: infer Params) => infer Return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ? T extends (...args: Params) => Promise + ? T + : (...args: Params) => Promise + : never; + +/** + * Asynchronous instance. + * + * All methods are converted to their asynchronous version, and properties are available asynchronously using + * the getProperty method. + */ +export type AsyncInstance = AsyncInstanceWrapper & { + [k in keyof T]: AsyncMethod; +}; + +/** + * Create an asynchronous instance proxy, where all methods will be callable directly but will become asynchronous. If the + * underlying instance hasn't been set, methods will be resolved once it is. + * + * @param lazyConstructor Constructor to use the first time the instance is needed. + * @returns Asynchronous instance. + */ +export function asyncInstance(lazyConstructor?: () => T | Promise): AsyncInstance { + const wrapper = createAsyncInstanceWrapper(lazyConstructor); + + return new Proxy(wrapper, { + get: (target, property, receiver) => { + if (property in target) { + return Reflect.get(target, property, receiver); + } + + return async (...args: unknown[]) => { + const instance = await wrapper.getInstance(); + + return instance[property](...args); + }; + }, + }) as AsyncInstance; +} diff --git a/src/core/utils/lazy-map.ts b/src/core/utils/lazy-map.ts new file mode 100644 index 000000000..3570c111c --- /dev/null +++ b/src/core/utils/lazy-map.ts @@ -0,0 +1,40 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Lazy map. + * + * Lazy maps are empty by default, but entries are generated lazily when accessed. + */ +export type LazyMap = Record; + +/** + * Create a map that will initialize entries lazily with the given constructor. + * + * @param lazyConstructor Constructor to use the first time an entry is accessed. + * @returns Lazy map. + */ +export function lazyMap(lazyConstructor: (key: string) => T): LazyMap { + const instances = {}; + + return new Proxy(instances, { + get(target, property, receiver) { + if (!(property in instances)) { + target[property] = lazyConstructor(property.toString()); + } + + return Reflect.get(target, property, receiver); + }, + }); +} diff --git a/src/types/config.d.ts b/src/types/config.d.ts index c5627bc99..c8fa468bc 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -17,6 +17,7 @@ import { CoreMainMenuLocalizedCustomItem } from '@features/mainmenu/services/mai import { CoreSitesDemoSiteData } from '@services/sites'; import { OpenFileAction } from '@services/utils/utils'; import { CoreLoginSiteSelectorListMethod } from '@features/login/services/login-helper'; +import { CoreDatabaseConfiguration } from '@classes/database/database-table-proxy'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -31,6 +32,8 @@ export interface EnvironmentConfig { cache_update_frequency_rarely: number; default_lang: string; languages: Record; + databaseOptimizations?: Partial; + databaseTableOptimizations?: Record>; wsservice: string; demo_sites: Record; zoomlevels: Record;