diff --git a/companion/lib/Cloud/Controller.ts b/companion/lib/Cloud/Controller.ts index 320036f95..ae119a512 100644 --- a/companion/lib/Cloud/Controller.ts +++ b/companion/lib/Cloud/Controller.ts @@ -6,13 +6,23 @@ import { xyToOldBankIndex } from '@companion-app/shared/ControlId.js' import { delay } from '../Resources/Util.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' import type { Registry } from '../Registry.js' -import type { CloudDatabase } from '../Data/CloudDatabase.js' import type { DataCache } from '../Data/Cache.js' import type { ClientSocket } from '../UI/Handler.js' import type { ImageResult } from '../Graphics/ImageResult.js' +import nodeMachineId from 'node-machine-id' const CLOUD_URL = 'https://api.bitfocus.io/v1' +function generateMachineId() { + try { + return nodeMachineId.machineIdSync(true) + } catch (e) { + // The nodeMachineId call can fail if the machine has stricter security that blocks regedit + // If that happens, fallback to a uuid, which while not stable, is better than nothing + return v4() + } +} + /** * The class that manages the Bitfocus Cloud functionality * @@ -80,16 +90,14 @@ export class CloudController extends CoreBase { canActivate: false, } - readonly clouddb: CloudDatabase readonly cache: DataCache - constructor(registry: Registry, clouddb: CloudDatabase, cache: DataCache) { + constructor(registry: Registry, cache: DataCache) { super(registry, 'Cloud/Controller') - this.clouddb = clouddb this.cache = cache - this.data = this.clouddb.getKey('auth', { + this.data = this.db.getTableKey('cloud', 'auth', { token: '', user: '', connections: {}, @@ -97,14 +105,16 @@ export class CloudController extends CoreBase { }) this.companionId = registry.appInfo.machineId - const uuid = this.clouddb.getKey('uuid', undefined) + const uuid = this.db.getTableKey('cloud', 'uuid', generateMachineId()) this.#setState({ uuid }) - const regions = this.cache.getKey('cloud_servers', undefined) + const regions = this.cache.getKey('cloud_servers', {}) if (regions !== undefined) { - for (const region of regions) { + for (const region of Object.values(regions)) { + /** @ts-ignore */ if (region.id && region.label && region.hostname) { + /** @ts-ignore */ CloudController.availableRegions[region.id] = { host: region.hostname, name: region.label } } } @@ -313,7 +323,7 @@ export class CloudController extends CoreBase { async #handleCloudRegenerateUUID(_client: ClientSocket): Promise { const newUuid = v4() this.#setState({ uuid: newUuid }) - this.clouddb.setKey('uuid', newUuid) + this.db.setTableKey('cloud', 'uuid', newUuid) this.#setState({ cloudActive: false }) await delay(1000) @@ -376,7 +386,7 @@ export class CloudController extends CoreBase { if (responseObject.token !== undefined) { this.data.token = responseObject.token this.data.user = email - this.clouddb.setKey('auth', this.data) + this.db.setTableKey('cloud', 'auth', this.data) this.#setState({ authenticated: true, authenticating: false, authenticatedAs: email, error: null }) this.#readConnections(this.data.connections) } else { @@ -399,7 +409,7 @@ export class CloudController extends CoreBase { this.data.token = '' this.data.connections = {} this.data.cloudActive = false - this.clouddb.setKey('auth', this.data) + this.db.setTableKey('cloud', 'auth', this.data) this.#setState({ authenticated: false, @@ -438,7 +448,7 @@ export class CloudController extends CoreBase { if (result.token) { this.data.token = result.token - this.clouddb.setKey('auth', this.data) + this.db.setTableKey('cloud', 'auth', this.data) this.#setState({ authenticated: true, authenticatedAs: result.customer?.email, @@ -550,7 +560,7 @@ export class CloudController extends CoreBase { this.data.connections[region] = enabled - this.clouddb.setKey('auth', this.data) + this.db.setTableKey('cloud', 'auth', this.data) } /** @@ -576,7 +586,7 @@ export class CloudController extends CoreBase { if (oldState.cloudActive !== newState.cloudActive) { this.data.cloudActive = newState.cloudActive - this.clouddb.setKey('auth', this.data) + this.db.setTableKey('cloud', 'auth', this.data) if (newState.authenticated) { for (let region in this.#regionInstances) { diff --git a/companion/lib/Controls/ControlBase.ts b/companion/lib/Controls/ControlBase.ts index 1b5b68ded..f1bd1ba8b 100644 --- a/companion/lib/Controls/ControlBase.ts +++ b/companion/lib/Controls/ControlBase.ts @@ -73,7 +73,7 @@ export abstract class ControlBase extends CoreBase { const newJson = this.toJSON(true) // Save to db - this.db.setKey(['controls', this.controlId], newJson as any) + this.db.setTableKey('controls', this.controlId, newJson as any) // Now broadcast to any interested clients const roomName = ControlConfigRoom(this.controlId) diff --git a/companion/lib/Controls/Controller.ts b/companion/lib/Controls/Controller.ts index 18f2fe471..8e309689a 100644 --- a/companion/lib/Controls/Controller.ts +++ b/companion/lib/Controls/Controller.ts @@ -738,7 +738,7 @@ export class ControlsController extends CoreBase { this.#controls.delete(controlId) - this.db.setKey(['controls', controlId], undefined) + this.db.deleteTableKey('controls', controlId) return true } @@ -1049,7 +1049,7 @@ export class ControlsController extends CoreBase { */ init(): void { // Init all the control classes - const config: Record = this.db.getKey('controls', {}) + const config: Record = this.db.getTable('controls') for (const [controlId, controlObj] of Object.entries(config)) { if (controlObj && controlObj.type) { const inst = this.#createClassForControl(controlId, 'all', controlObj, false) @@ -1130,7 +1130,7 @@ export class ControlsController extends CoreBase { control.destroy() this.#controls.delete(controlId) - this.db.setKey(['controls', controlId], undefined) + this.db.deleteTableKey('controls', controlId) } const location = this.page.getLocationOfControlId(controlId) diff --git a/companion/lib/Data/Cache.ts b/companion/lib/Data/Cache.ts index d05c405e6..03eb70024 100644 --- a/companion/lib/Data/Cache.ts +++ b/companion/lib/Data/Cache.ts @@ -1,4 +1,5 @@ -import { DataStoreBase } from './StoreBase.js' +import { DatabaseDefault, DataStoreBase } from './StoreBase.js' +import { DataLegacyCache } from './Legacy/Cache.js' /** * The class that manages the applications's disk cache @@ -24,18 +25,62 @@ export class DataCache extends DataStoreBase { /** * The stored defaults for a new cache */ - private static Defaults: object = {} - /** - * The default minimum interval in ms to save to disk (30000 ms) - */ - private static SaveInterval: number = 30000 + static Defaults: DatabaseDefault = { + main: { + cloud_servers: {}, + }, + } /** * @param configDir - the root config directory */ constructor(configDir: string) { - super(configDir, 'datacache', DataCache.SaveInterval, DataCache.Defaults, 'Data/Cache') + super(configDir, 'datacache', 'main', 'Data/Cache') + + this.startSQLite() + } + + /** + * Create the database tables + */ + protected create(): void { + if (this.store) { + const create = this.store.prepare( + `CREATE TABLE IF NOT EXISTS ${this.defaultTable} (id STRING UNIQUE, value STRING);` + ) + try { + create.run() + } catch (e) { + this.logger.warn(`Error creating table ${this.defaultTable}`) + } + } + } + + /** + * Save the defaults since a file could not be found/loaded/parsed + */ + protected loadDefaults(): void { + this.create() + + for (const [key, value] of Object.entries(DataCache.Defaults)) { + this.setKey(key, value) + } + + this.isFirstRun = true + } + + /** + * Skip loading migrating the old DB to SQLite + */ + protected migrateFileToSqlite(): void { + this.create() + + const legacyDB = new DataLegacyCache(this.cfgDir) + + const data = legacyDB.getAll() - this.loadSync() + for (const [key, value] of Object.entries(data)) { + this.setKey(key, value) + } } } diff --git a/companion/lib/Data/Database.ts b/companion/lib/Data/Database.ts index 6df73203f..b1087a2e2 100644 --- a/companion/lib/Data/Database.ts +++ b/companion/lib/Data/Database.ts @@ -1,4 +1,8 @@ -import { DataStoreBase } from './StoreBase.js' +import { DatabaseDefault, DataStoreBase } from './StoreBase.js' +import { DataLegacyDatabase } from './Legacy/Database.js' +import { createTables as createTablesV1 } from './Schema/v1.js' +import { createTables as createTablesV5 } from './Schema/v5.js' + import { upgradeStartup } from './Upgrade.js' /** @@ -25,22 +29,56 @@ export class DataDatabase extends DataStoreBase { /** * The stored defaults for a new db */ - static Defaults: object = { - page_config_version: 3, + static Defaults: DatabaseDefault = { + main: { + page_config_version: 5, + }, } - /** - * The default minimum interval in ms to save to disk (4000 ms) - */ - private static SaveInterval: number = 4000 /** * @param configDir - the root config directory */ constructor(configDir: string) { - super(configDir, 'db', DataDatabase.SaveInterval, DataDatabase.Defaults, 'Data/Database') + super(configDir, 'db', 'main', 'Data/Database') - this.loadSync() + this.startSQLite() upgradeStartup(this) } + + /** + * Create the database tables + */ + protected create(): void { + createTablesV5(this.store, this.defaultTable, this.logger) + } + + /** + * Save the defaults since a file could not be found/loaded/parsed + */ + protected loadDefaults(): void { + this.create() + + /** @ts-ignore */ + for (const [key, value] of Object.entries(DataDatabase.Defaults)) { + this.setKey(key, value) + } + + this.isFirstRun = true + } + + /** + * Load the old file driver and migrate to SQLite + */ + protected migrateFileToSqlite(): void { + createTablesV1(this.store, this.defaultTable, this.logger) + + const legacyDB = new DataLegacyDatabase(this.cfgDir) + + const data = legacyDB.getAll() + + for (const [key, value] of Object.entries(data)) { + this.setKey(key, value) + } + } } diff --git a/companion/lib/Data/ImportExport.ts b/companion/lib/Data/ImportExport.ts index 50434c378..74c76d4b5 100644 --- a/companion/lib/Data/ImportExport.ts +++ b/companion/lib/Data/ImportExport.ts @@ -434,14 +434,6 @@ export class DataImportExport extends CoreBase { archive.append(out, { name: 'log.csv' }) } - try { - const _db = this.db.getAll() - const out = JSON.stringify(_db) - archive.append(out, { name: 'db.ram' }) - } catch (e) { - this.logger.debug(`Support bundle append db: ${e}`) - } - try { const payload = this.registry.ui.update.compilePayload() let out = JSON.stringify(payload) @@ -520,6 +512,7 @@ export class DataImportExport extends CoreBase { if (object.instances) { for (const inst of Object.values(object.instances)) { if (inst) { + /** @ts-ignore */ inst.lastUpgradeIndex = inst.lastUpgradeIndex ?? -1 } } @@ -583,6 +576,7 @@ export class DataImportExport extends CoreBase { for (const [id, trigger] of Object.entries(object.triggers)) { clientObject.triggers[id] = { + /** @ts-ignore */ name: trigger.options.name, } } diff --git a/companion/lib/Data/Legacy/Cache.ts b/companion/lib/Data/Legacy/Cache.ts new file mode 100644 index 000000000..632ce3714 --- /dev/null +++ b/companion/lib/Data/Legacy/Cache.ts @@ -0,0 +1,32 @@ +import { DataLegacyStoreBase } from './StoreBase.js' + +/** + * The class that manages the applications's disk cache + * + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 2.3.0 + * @copyright 2022 Bitfocus AS + * @license + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for Companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + */ +export class DataLegacyCache extends DataLegacyStoreBase { + /** + * @param configDir - the root config directory + */ + constructor(configDir: string) { + super(configDir, 'datacache', 'Data/Legacy/Cache') + + this.loadSync() + } +} diff --git a/companion/lib/Data/CloudDatabase.ts b/companion/lib/Data/Legacy/CloudDatabase.ts similarity index 50% rename from companion/lib/Data/CloudDatabase.ts rename to companion/lib/Data/Legacy/CloudDatabase.ts index b8ac0b284..5eab8afef 100644 --- a/companion/lib/Data/CloudDatabase.ts +++ b/companion/lib/Data/Legacy/CloudDatabase.ts @@ -1,16 +1,4 @@ -import { v4 } from 'uuid' -import { DataStoreBase } from './StoreBase.js' -import nodeMachineId from 'node-machine-id' - -function generateMachineId() { - try { - return nodeMachineId.machineIdSync(true) - } catch (e) { - // The nodeMachineId call can fail if the machine has stricter security that blocks regedit - // If that happens, fallback to a uuid, which while not stable, is better than nothing - return v4() - } -} +import { DataLegacyStoreBase } from './StoreBase.js' /** * The class that manages the applications's cloud config database @@ -32,30 +20,12 @@ function generateMachineId() { * develop commercial activities involving the Companion software without * disclosing the source code of your own applications. */ -export class CloudDatabase extends DataStoreBase { - /** - * The stored defaults for a new db - */ - private static Defaults: object = { - uuid: generateMachineId(), - auth: { - token: '', - user: '', - connections: {}, - cloudActive: false, - }, - } - - /** - * The default minimum interval in ms to save to disk (4000 ms) - */ - private static SaveInterval: number = 4000 - +export class DataLegacyCloudDatabase extends DataLegacyStoreBase { /** * @param configDir - the root config directory */ constructor(configDir: string) { - super(configDir, 'cloud', CloudDatabase.SaveInterval, CloudDatabase.Defaults, 'Data/CloudDatabase') + super(configDir, 'cloud', 'Data/Legacy/CloudDatabase') this.loadSync() } diff --git a/companion/lib/Data/Legacy/Database.ts b/companion/lib/Data/Legacy/Database.ts new file mode 100644 index 000000000..84df71849 --- /dev/null +++ b/companion/lib/Data/Legacy/Database.ts @@ -0,0 +1,32 @@ +import { DataLegacyStoreBase } from './StoreBase.js' + +/** + * The class that manages the applications's main database + * + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 1.0.4 + * @copyright 2022 Bitfocus AS + * @license + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for Companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + */ +export class DataLegacyDatabase extends DataLegacyStoreBase { + /** + * @param configDir - the root config directory + */ + constructor(configDir: string) { + super(configDir, 'db', 'Data/Legacy/Database') + + this.loadSync() + } +} diff --git a/companion/lib/Data/Legacy/StoreBase.ts b/companion/lib/Data/Legacy/StoreBase.ts new file mode 100644 index 000000000..fe3acc3fd --- /dev/null +++ b/companion/lib/Data/Legacy/StoreBase.ts @@ -0,0 +1,261 @@ +import fs from 'fs-extra' +import path from 'path' +import { cloneDeep } from 'lodash-es' +import LogController, { Logger } from '../../Log/Controller.js' +import { showErrorMessage } from '../../Resources/Util.js' + +/** + * Abstract class to be extended by the flat file DB classes. + * See {@link DataCache} and {@link DataDatabase} + * + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 2.3.0 + * @copyright 2022 Bitfocus AS + * @license + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for Companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + */ +export class DataLegacyStoreBase { + protected readonly logger: Logger + + /** + * The full backup file path + */ + private readonly cfgBakFile: string = '' + /** + * The full corrupt file path + */ + private readonly cfgCorruptFile: string = '' + /** + * The config directory + */ + private readonly cfgDir: string = '' + /** + * The full main file path + */ + private readonly cfgFile: string = '' + /** + * The stored defaults for a new store + */ + private readonly defaults: object = {} + /** + * The name to use for the file and logging + */ + private readonly name: string = '' + + /** + * The flat file DB in RAM + */ + store: Record = {} + + /** + * This needs to be called in the extending class + * using super(registry, name, debug). + * @param configDir - the root config directory + * @param name - the name of the flat file + * @param debug - module path to be used in the debugger + */ + constructor(configDir: string, name: string, debug: string) { + this.logger = LogController.createLogger(debug) + + this.cfgDir = configDir + this.name = name + + this.cfgFile = path.join(this.cfgDir, this.name) + this.cfgBakFile = path.join(this.cfgDir, this.name + '.bak') + this.cfgCorruptFile = path.join(this.cfgDir, this.name + '.corrupt') + } + + /** + * Delete a key/value pair + * @param key - the key to be delete + */ + deleteKey(key: string): void { + this.logger.silly(`${this.name}_del (${key})`) + if (key !== undefined) { + delete this.store[key] + } + } + + /** + * Get the entire database + * @param clone - true if a clone is needed instead of a link + * @returns the database + */ + getAll(clone = false): any { + let out + this.logger.silly(`${this.name}_all`) + + if (clone === true) { + out = cloneDeep(this.store) + } else { + out = this.store + } + + return out + } + + /** + * @returns the directory of the flat file + */ + getCfgDir(): string { + return this.cfgDir + } + + /** + * @returns the flat file + */ + getCfgFile(): string { + return this.cfgFile + } + + /** + * @returns JSON of the database + */ + getJSON(): string | null { + try { + return JSON.stringify(this.store) + } catch (e) { + this.logger.silly(`JSON error: ${e}`) + return null + } + } + + /** + * Get a value from the database + * @param key - the key to be retrieved + * @param defaultValue - the default value to use if the key doesn't exist + * @param clone - true if a clone is needed instead of a link + */ + getKey(key: string, defaultValue?: any, clone = false): any { + let out + this.logger.silly(`${this.name}_get(${key})`) + + if (this.store[key] === undefined && defaultValue !== undefined) { + this.store[key] = defaultValue + } + + if (clone === true) { + out = cloneDeep(this.store[key]) + } else { + out = this.store[key] + } + + return out + } + + /** + * Checks if the database has a value + * @param key - the key to be checked + */ + hasKey(key: string): boolean { + return this.store[key] !== undefined + } + + /** + * Attempt to load the database from disk + * @access protected + */ + protected loadSync(): void { + if (fs.existsSync(this.cfgFile)) { + this.logger.silly(this.cfgFile, 'exists. trying to read') + + try { + let data = fs.readFileSync(this.cfgFile, 'utf8') + + if (data.trim().length > 0 || data.startsWith('\0')) { + this.store = JSON.parse(data) + this.logger.silly('parsed JSON') + } else { + this.logger.warn(`${this.name} was empty. Attempting to recover the configuration.`) + this.loadBackupSync() + } + } catch (e) { + try { + fs.copyFileSync(this.cfgFile, this.cfgCorruptFile) + this.logger.error(`${this.name} could not be parsed. A copy has been saved to ${this.cfgCorruptFile}.`) + fs.rmSync(this.cfgFile) + } catch (err) { + this.logger.silly(`${this.name}_load`, `Error making or deleting corrupted backup: ${err}`) + } + + this.loadBackupSync() + } + } else if (fs.existsSync(this.cfgBakFile)) { + this.logger.warn(`${this.name} is missing. Attempting to recover the configuration.`) + this.loadBackupSync() + } else { + this.logger.silly(this.cfgFile, `doesn't exist. loading defaults`, this.defaults) + } + } + + /** + * Attempt to load the backup file from disk as a recovery + */ + protected loadBackupSync(): void { + if (fs.existsSync(this.cfgBakFile)) { + this.logger.silly(this.cfgBakFile, 'exists. trying to read') + let data = fs.readFileSync(this.cfgBakFile, 'utf8') + + try { + if (data.trim().length > 0 || data.startsWith('\0')) { + this.store = JSON.parse(data) + this.logger.silly('parsed JSON') + this.logger.warn(`${this.name}.bak has been used to recover the configuration.`) + } else { + this.logger.warn(`${this.name} was empty. Creating a new db.`) + } + } catch (e) { + showErrorMessage('Error starting companion', 'Could not load database backup file. Resetting configuration') + + console.error('Could not load database backup file') + } + } else { + showErrorMessage('Error starting companion', 'Could not load database backup file. Resetting configuration') + + console.error('Could not load database file') + } + } + + /** + * Save/update a key/value pair to the database + * @param key - the key to save under + * @param value - the object to save + * @access public + */ + setKey(key: number | string | string[], value: any): void { + this.logger.silly(`${this.name}_set(${key}, ${value})`) + + if (key !== undefined) { + if (Array.isArray(key)) { + if (key.length > 0) { + const keyStr = key.join(':') + const lastK = key.pop() + + // Find or create the parent object + let dbObj = this.store + for (const k of key) { + if (!dbObj || typeof dbObj !== 'object') throw new Error(`Unable to set db path: ${keyStr}`) + if (!dbObj[k]) dbObj[k] = {} + dbObj = dbObj[k] + } + + // @ts-ignore + dbObj[lastK] = value + } + } else { + this.store[key] = value + } + } + } +} diff --git a/companion/lib/Data/Schema/v1.ts b/companion/lib/Data/Schema/v1.ts new file mode 100644 index 000000000..c603dcf28 --- /dev/null +++ b/companion/lib/Data/Schema/v1.ts @@ -0,0 +1,13 @@ +import { Database as SQLiteDB } from 'better-sqlite3' +import { Logger } from '../../Log/Controller.js' + +export function createTables(store: SQLiteDB | undefined, defaultTable: string, logger: Logger) { + if (store) { + try { + const create = store.prepare(`CREATE TABLE IF NOT EXISTS ${defaultTable} (id STRING UNIQUE, value STRING);`) + create.run() + } catch (e) { + logger.warn(`Error creating table ${defaultTable}`) + } + } +} diff --git a/companion/lib/Data/Schema/v5.ts b/companion/lib/Data/Schema/v5.ts new file mode 100644 index 000000000..ceb16786d --- /dev/null +++ b/companion/lib/Data/Schema/v5.ts @@ -0,0 +1,17 @@ +import { Database as SQLiteDB } from 'better-sqlite3' +import { Logger } from '../../Log/Controller.js' + +export function createTables(store: SQLiteDB | undefined, defaultTable: string, logger: Logger) { + if (store) { + try { + const main = store.prepare(`CREATE TABLE IF NOT EXISTS ${defaultTable} (id STRING UNIQUE, value STRING);`) + main.run() + const controls = store.prepare(`CREATE TABLE IF NOT EXISTS controls (id STRING UNIQUE, value STRING);`) + controls.run() + const cloud = store.prepare(`CREATE TABLE IF NOT EXISTS cloud (id STRING UNIQUE, value STRING);`) + cloud.run() + } catch (e) { + logger.warn(`Error creating tables`, e) + } + } +} diff --git a/companion/lib/Data/StoreBase.ts b/companion/lib/Data/StoreBase.ts index 44c9bafa7..2c33ad4b1 100644 --- a/companion/lib/Data/StoreBase.ts +++ b/companion/lib/Data/StoreBase.ts @@ -1,11 +1,13 @@ import fs from 'fs-extra' import path from 'path' -import { cloneDeep } from 'lodash-es' +import Database, { Database as SQLiteDB } from 'better-sqlite3' import LogController, { Logger } from '../Log/Controller.js' import { showErrorMessage } from '../Resources/Util.js' +export type DatabaseDefault = Record + /** - * Abstract class to be extended by the flat file DB classes. + * Abstract class to be extended by the DB classes. * See {@link DataCache} and {@link DataDatabase} * * @author Håkon Nessjøen @@ -25,42 +27,37 @@ import { showErrorMessage } from '../Resources/Util.js' * develop commercial activities involving the Companion software without * disclosing the source code of your own applications. */ -export class DataStoreBase { +export abstract class DataStoreBase { protected readonly logger: Logger - /** - * The full backup file path + * The time to use for the save interval */ - private readonly cfgBakFile: string = '' + private backupCycle: NodeJS.Timeout | undefined /** - * The full corrupt file path + * The interval to fire a backup to disk when dirty */ - private readonly cfgCorruptFile: string = '' + private readonly backupInterval: number = 60000 /** * The config directory */ - private readonly cfgDir: string = '' + public readonly cfgDir: string /** * The full main file path */ - private readonly cfgFile: string = '' - /** - * The full temporary file path - */ - private readonly cfgTmpFile: string = '' + protected readonly cfgFile: string /** - * The stored defaults for a new store + * The default table to dumb keys when one isn't specified */ - private readonly defaults: object = {} + protected readonly defaultTable: string /** - * Flag to tell the saveInternal there's - * changes to save to disk + * Flag to tell the backupInternal there's + * changes to backup to disk */ private dirty = false /** * Flag if this database was created fresh on this run */ - private isFirstRun = false + protected isFirstRun = false /** * Timestamp of last save to disk */ @@ -68,305 +65,213 @@ export class DataStoreBase { /** * The name to use for the file and logging */ - private readonly name: string = '' - + protected readonly name: string = '' /** - * The time to use for the save interval + * The SQLite database */ - private saveCycle: NodeJS.Timeout | undefined - - /** - * The interval to fire a save to disk when dirty - */ - private readonly saveInterval: number - - /** - * Semaphore while the store is saving to disk - */ - private saving = false - - /** - * The flat file DB in RAM - */ - store: Record = {} + public store: SQLiteDB | undefined /** * This needs to be called in the extending class * using super(registry, name, saveInterval, defaults, debug). * @param configDir - the root config directory * @param name - the name of the flat file - * @param saveInterval - minimum interval in ms to save to disk - * @param defaults - the default data to use when making a new file + * @param defaultTable - the default table for data * @param debug - module path to be used in the debugger */ - constructor(configDir: string, name: string, saveInterval: number, defaults: object, debug: string) { + constructor(configDir: string, name: string, defaultTable: string, debug: string) { this.logger = LogController.createLogger(debug) this.cfgDir = configDir this.name = name - this.saveInterval = saveInterval - this.defaults = defaults + this.defaultTable = defaultTable this.cfgFile = path.join(this.cfgDir, this.name) - this.cfgBakFile = path.join(this.cfgDir, this.name + '.bak') - this.cfgCorruptFile = path.join(this.cfgDir, this.name + '.corrupt') - this.cfgTmpFile = path.join(this.cfgDir, this.name + '.tmp') } /** - * Delete a key/value pair - * @param key - the key to be delete + * Create the database tables */ - deleteKey(key: string): void { - this.logger.silly(`${this.name}_del (${key})`) - if (key !== undefined) { - delete this.store[key] - this.setDirty() - } - } + protected abstract create(): void /** - * Save the database to file making a `FILE.bak` version then moving it into place - * @param withBackup - can be set to false if the current file should not be moved to `FILE.bak` + * Close the file because we're existing */ - protected async doSave(withBackup = true): Promise { - const jsonSave = JSON.stringify(this.store) - this.dirty = false - this.lastsave = Date.now() - - if (withBackup) { - try { - const file = await fs.readFile(this.cfgFile, 'utf8') - - if (file.trim().length > 0) { - JSON.parse(file) // just want to see if a parse error is thrown so we don't back up a corrupted db - - try { - await fs.copy(this.cfgFile, this.cfgBakFile) - this.logger.silly(`${this.name}_save: backup written`) - } catch (err) { - this.logger.silly(`${this.name}_save: Error making backup copy: ${err}`) - } - } - } catch (err) { - this.logger.silly(`${this.name}_save: Error checking db file for backup: ${err}`) - } - } - - try { - await fs.writeFile(this.cfgTmpFile, jsonSave) - } catch (err) { - this.logger.silly(`${this.name}_save: Error saving: ${err}`) - throw 'Error saving: ' + err - } - - this.logger.silly(`${this.name}_save: written`) - - try { - await fs.rename(this.cfgTmpFile, this.cfgFile) - } catch (err) { - this.logger.silly(`${this.name}_save: Error renaming ${this.name}.tmp: ` + err) - throw `Error renaming ${this.name}.tmp: ` + err - } - - this.logger.silly(`${this.name}_save: renamed`) + public close(): void { + this.store?.close() } /** - * Get the entire database - * @param clone - true if a clone is needed instead of a link - * @returns the database + * Delete a key/value pair from the default table + * @param key - the key to be delete */ - getAll(clone = false): Record { - let out - this.logger.silly(`${this.name}_all`) - - if (clone === true) { - out = cloneDeep(this.store) - } else { - out = this.store - } - - return out + public deleteKey(key: string): void { + this.deleteTableKey(this.defaultTable, key) } /** - * @returns the directory of the flat file + * Delete a key/value pair from a table + * @param table - the table to delete from + * @param key - the key to be delete */ - getCfgDir(): string { - return this.cfgDir - } + public deleteTableKey(table: string, key: string): void { + if (table.length > 0 && key.length > 0 && this.store) { + const query = this.store.prepare(`DELETE FROM ${table} WHERE id = @id`) + this.logger.silly(`Delete key: ${table} - ${key}`) - /** - * @returns the flat file - */ - getCfgFile(): string { - return this.cfgFile + try { + query.run({ id: key }) + } catch (e) { + this.logger.warn(`Error deleting ${key}`, e) + } + + this.setDirty() + } } /** * @returns the 'is first run' flag */ - getIsFirstRun(): boolean { + public getIsFirstRun(): boolean { return this.isFirstRun } /** - * @returns JSON of the database + * Get a value from the default table + * @param key - the to be retrieved + * @param defaultValue - the default value to use if the key doens't exist + * @returns the value */ - getJSON(): string | null { - try { - return JSON.stringify(this.store) - } catch (e) { - this.logger.silly(`JSON error: ${e}`) - return null - } + public getKey(key: string, defaultValue?: any): any { + return this.getTableKey(this.defaultTable, key, defaultValue) } /** - * Get a value from the database - * @param key - the key to be retrieved - * @param defaultValue - the default value to use if the key doesn't exist - * @param clone - true if a clone is needed instead of a link + * Get all rows from a table + * @param table - the table to get from + * @returns the rows */ - getKey(key: string, defaultValue?: any, clone = false): any { - let out - this.logger.silly(`${this.name}_get(${key})`) + public getTable(table: string): any { + let out = {} - if (this.store[key] === undefined && defaultValue !== undefined) { - this.store[key] = defaultValue - this.setDirty() - } + if (table.length > 0 && this.store) { + const query = this.store.prepare(`SELECT id, value FROM ${table}`) + this.logger.silly(`Get table: ${table}`) - if (clone === true) { - out = cloneDeep(this.store[key]) - } else { - out = this.store[key] + try { + const rows = query.all() + + if (rows.length > 0) { + for (const record of Object.values(rows)) { + try { + /** @ts-ignore */ + out[record.id] = JSON.parse(record.value) + } catch (e) { + /** @ts-ignore */ + out[record.id] = record.value + } + } + } + } catch (e) { + this.logger.warn(`Error getting ${table}`, e) + } } return out } /** - * Checks if the database has a value - * @param key - the key to be checked + * Get a value from a table + * @param table - the table to get from + * @param key - the key to be retrieved + * @param defaultValue - the default value to use if the key doesn't exist + * @returns the value */ - hasKey(key: string): boolean { - return this.store[key] !== undefined - } + public getTableKey(table: string, key: string, defaultValue?: any): any { + let out - /** - * Attempt to load the database from disk - * @access protected - */ - protected loadSync(): void { - if (fs.existsSync(this.cfgFile)) { - this.logger.silly(this.cfgFile, 'exists. trying to read') + if (table.length > 0 && key.length > 0 && this.store) { + const query = this.store.prepare(`SELECT value FROM ${table} WHERE id = @id`) + this.logger.silly(`Get table key: ${table} - ${key}`) try { - let data = fs.readFileSync(this.cfgFile, 'utf8') - - if (data.trim().length > 0 || data.startsWith('\0')) { - this.store = JSON.parse(data) - this.logger.silly('parsed JSON') + const row = query.get({ id: key }) + /** @ts-ignore */ + if (row && row.value) { + try { + /** @ts-ignore */ + out = JSON.parse(row.value) + } catch (e) { + /** @ts-ignore */ + out = row.value + } } else { - this.logger.warn(`${this.name} was empty. Attempting to recover the configuration.`) - this.loadBackupSync() + this.logger.silly(`Get table key: ${table} - ${key} failover`) + this.setTableKey(table, key, defaultValue) + out = defaultValue } } catch (e) { - try { - fs.copyFileSync(this.cfgFile, this.cfgCorruptFile) - this.logger.error(`${this.name} could not be parsed. A copy has been saved to ${this.cfgCorruptFile}.`) - fs.rmSync(this.cfgFile) - } catch (err) { - this.logger.silly(`${this.name}_load`, `Error making or deleting corrupted backup: ${err}`) - } - - this.loadBackupSync() + this.logger.warn(`Error getting ${key}`, e) } - } else if (fs.existsSync(this.cfgBakFile)) { - this.logger.warn(`${this.name} is missing. Attempting to recover the configuration.`) - this.loadBackupSync() - } else { - this.logger.silly(this.cfgFile, `doesn't exist. loading defaults`, this.defaults) - this.loadDefaults() - } - this.#setSaveCycle() + this.setDirty() + } + return out } /** - * Attempt to load the backup file from disk as a recovery + * Checks if the main table has a value + * @param key - the key to be checked */ - protected loadBackupSync(): void { - if (fs.existsSync(this.cfgBakFile)) { - this.logger.silly(this.cfgBakFile, 'exists. trying to read') - let data = fs.readFileSync(this.cfgBakFile, 'utf8') + public hasKey(key: string): boolean { + let row - try { - if (data.trim().length > 0 || data.startsWith('\0')) { - this.store = JSON.parse(data) - this.logger.silly('parsed JSON') - this.logger.warn(`${this.name}.bak has been used to recover the configuration.`) - this.save(false) - } else { - this.logger.warn(`${this.name} was empty. Creating a new db.`) - this.loadDefaults() - } - } catch (e) { - showErrorMessage('Error starting companion', 'Could not load database backup file. Resetting configuration') - - console.error('Could not load database backup file') - this.loadDefaults() - } - } else { - showErrorMessage('Error starting companion', 'Could not load database backup file. Resetting configuration') - - console.error('Could not load database file') - this.loadDefaults() + if (this.store) { + const query = this.store.prepare(`SELECT id FROM ${this.defaultTable} WHERE id = @id`) + row = query.get({ id: key }) } + + return !!row } /** - * Save the defaults since a file could not be found/loaded/parses + * Save the defaults since a file could not be found/loaded/parsed */ - protected loadDefaults(): void { - this.store = cloneDeep(this.defaults) - this.isFirstRun = true - this.save() - } + protected abstract loadDefaults(): void /** - * Save the database to file - * @param withBackup - can be set to `false` if the current file should not be moved to `FILE.bak` + * Load the old file driver and migrate to SQLite */ - save(withBackup = true): void { - if (this.saving === false) { - this.logger.silly(`${this.name}_save: begin`) - this.saving = true + protected abstract migrateFileToSqlite(): void - this.doSave(withBackup) - .catch((err) => { - try { - this.logger.error(err) - } catch (err2) { - this.logger.silly(`${this.name}_save: Error reporting save failure: ${err2}`) - } - }) + /** + * Save a backup of the db + */ + private saveBackup(): void { + if (this.store) { + this.store + .backup(`${this.cfgFile}.sqlite.bak`) .then(() => { - // This will run even if the catch caught an error - this.saving = false + this.logger.silly('backup complete') + }) + .catch((err) => { + this.logger.warn('backup failed', err.message) }) } } /** - * Execute a save if the database is dirty + * Setup the save cycle interval */ - saveImmediate(): void { - if (this.dirty === true) { - this.save() - } + private setBackupCycle(): void { + if (this.backupCycle) return + + this.backupCycle = setInterval(() => { + // See if the database is dirty and needs to be saved + if (Date.now() - this.lastsave > this.backupInterval && this.dirty) { + this.saveBackup() + } + }, this.backupInterval) } /** @@ -377,67 +282,64 @@ export class DataStoreBase { } /** - * Save/update a key/value pair to the database + * Save/update a key/value pair to the default table * @param key - the key to save under * @param value - the object to save - * @access public */ - setKey(key: number | string | string[], value: any): void { - this.logger.silly(`${this.name}_set(${key}, ${value})`) - - if (key !== undefined) { - if (Array.isArray(key)) { - if (key.length > 0) { - const keyStr = key.join(':') - const lastK = key.pop() - - // Find or create the parent object - let dbObj = this.store - for (const k of key) { - if (!dbObj || typeof dbObj !== 'object') throw new Error(`Unable to set db path: ${keyStr}`) - if (!dbObj[k]) dbObj[k] = {} - dbObj = dbObj[k] - } + public setKey(key: string, value: any): void { + this.setTableKey(this.defaultTable, key, value) + } - // @ts-ignore - dbObj[lastK] = value - this.setDirty() - } - } else { - this.store[key] = value - this.setDirty() + /** + * Save/update a key/value pair to a table + * @param table - the table to save in + * @param key - the key to save under + * @param value - the object to save + */ + public setTableKey(table: string, key: string, value: any): void { + if (table.length > 0 && key.length > 0 && value && this.store) { + if (typeof value === 'object') { + value = JSON.stringify(value) } - } - } - // /** - // * Save/update multiple key/value pairs to the database - // * @access public - // */ - // setKeys(keyvalueobj) { - // this.logger.silly(`${this.name}_set_multiple:`) + const query = this.store.prepare( + `INSERT INTO ${table} (id, value) VALUES (@id, @value) ON CONFLICT(id) DO UPDATE SET value = @value` + ) + this.logger.silly(`Set table key ${table} - ${key} - ${value}`) - // if (keyvalueobj !== undefined && typeof keyvalueobj == 'object' && keyvalueobj.length > 0) { - // for (let key in keyvalueobj) { - // this.logger.silly(`${this.name}_set(${key}, ${keyvalueobj[key]})`) - // this.store[key] = keyvalueobj[key] - // } + try { + query.run({ id: key, value: value }) + } catch (e) { + this.logger.warn(`Error updating ${key}`, e) + } - // this.setDirty() - // } - // } + this.setDirty() + } + } /** - * Setup the save cycle interval + * Attempt to load the database */ - #setSaveCycle(): void { - if (this.saveCycle) return + protected startSQLite(): void { + try { + this.store = new Database(this.cfgFile + '.sqlite', { fileMustExist: true }) + this.setBackupCycle() + } catch (e) { + try { + this.store = new Database(this.cfgFile + '.sqlite') + this.setBackupCycle() + if (fs.existsSync(this.cfgFile)) { + this.logger.warn(`Legacy ${this.cfgFile} exists. Attempting migration to SQLite.`) + this.migrateFileToSqlite() + } else { + this.logger.silly(`${this.cfgFile}.sqlite doesn't exist. loading defaults`) + this.loadDefaults() + } + } catch (e) { + showErrorMessage('Error starting companion', 'Could not load or create a database file.') - this.saveCycle = setInterval(() => { - // See if the database is dirty and needs to be saved - if (Date.now() - this.lastsave > this.saveInterval && this.dirty) { - this.save() + console.error('Could not load or create a database file' + e) } - }, this.saveInterval) + } } } diff --git a/companion/lib/Data/Upgrade.ts b/companion/lib/Data/Upgrade.ts index fd5a50392..e2fe112e8 100644 --- a/companion/lib/Data/Upgrade.ts +++ b/companion/lib/Data/Upgrade.ts @@ -1,9 +1,9 @@ import LogController from '../Log/Controller.js' -import fs from 'fs-extra' import v1tov2 from './Upgrades/v1tov2.js' import v2tov3 from './Upgrades/v2tov3.js' import v3tov4 from './Upgrades/v3tov4.js' +import v4tov5 from './Upgrades/v4tov5.js' import { showFatalError } from '../Resources/Util.js' import type { DataDatabase } from './Database.js' import type { SomeExportv4 } from '@companion-app/shared/Model/ExportModel.js' @@ -14,6 +14,7 @@ const allUpgrades = [ v1tov2, // 15 to 32 key v2tov3, // v3.0 v3tov4, // v3.2 + v4tov5, // v3.5 ] const targetVersion = allUpgrades.length + 1 @@ -37,28 +38,12 @@ export function upgradeStartup(db: DataDatabase): void { } else { logger.info(`Upgrading db from version ${currentVersion} to ${targetVersion}`) - const saveUpgradeCopy = (db: DataDatabase, i: number) => { - try { - let jsonSave = db.getJSON() - - if (jsonSave) { - fs.writeFileSync(`${db.getCfgFile()}.v${i}`, jsonSave) - } - } catch (err) { - logger.warn(`db_save: Error saving upgrade copy: ${err}`) - } - } - // run the scripts for (let i = currentVersion; i < targetVersion; i++) { - saveUpgradeCopy(db, i) allUpgrades[i - 1].upgradeStartup(db, logger) } db.setKey('page_config_version', targetVersion) - - // force a save - db.saveImmediate() } } diff --git a/companion/lib/Data/Upgrades/v4tov5.ts b/companion/lib/Data/Upgrades/v4tov5.ts new file mode 100644 index 000000000..cc4e0b8c9 --- /dev/null +++ b/companion/lib/Data/Upgrades/v4tov5.ts @@ -0,0 +1,51 @@ +import { DataLegacyCloudDatabase } from '../Legacy/CloudDatabase.js' +import type { DataDatabase } from '../Database.js' +import type { Logger } from '../../Log/Controller.js' + +/** + * do the database upgrades to convert from the v4 to the v5 format + */ +function convertDatabaseToV5(db: DataDatabase, _logger: Logger) { + if (db.store) { + try { + const controls = db.store.prepare(`CREATE TABLE IF NOT EXISTS controls (id STRING UNIQUE, value STRING);`) + controls.run() + const cloud = db.store.prepare(`CREATE TABLE IF NOT EXISTS cloud (id STRING UNIQUE, value STRING);`) + cloud.run() + } catch (e) { + _logger.warn(`Error creating tables`, e) + } + + const batchInsert = function (table: string, heap: any) { + if (heap) { + for (const [key, value] of Object.entries(heap)) { + db.setTableKey(table, key, value) + } + } + } + + // Move controls to their new table + const controls = db.getKey('controls') + batchInsert('controls', controls) + db.deleteKey('controls') + + // Migrate the legacy cloud DB to its new table + const clouddb = new DataLegacyCloudDatabase(db.cfgDir) + const cloud = clouddb.getAll() + batchInsert('cloud', cloud) + + // Move surface-groups to match others + const surfaces = db.getKey('surface-groups', {}) + db.setKey('surface_groups', surfaces) + db.deleteKey('surface-groups') + } +} + +function convertImportToV5(obj: any) { + return obj +} + +export default { + upgradeStartup: convertDatabaseToV5, + upgradeImport: convertImportToV5, +} diff --git a/companion/lib/Registry.ts b/companion/lib/Registry.ts index 845c2b857..afd55c8ad 100644 --- a/companion/lib/Registry.ts +++ b/companion/lib/Registry.ts @@ -8,7 +8,6 @@ import { GraphicsController } from './Graphics/Controller.js' import { GraphicsPreview } from './Graphics/Preview.js' import { DataController } from './Data/Controller.js' import { DataDatabase } from './Data/Database.js' -import { CloudDatabase } from './Data/CloudDatabase.js' import { DataUserConfig } from './Data/UserConfig.js' import { InstanceController } from './Instance/Controller.js' import { InternalController } from './Internal/Controller.js' @@ -72,10 +71,8 @@ export interface RegistryEvents { */ export class Registry extends EventEmitter { /** - * The cloud database + * The cloud controller */ - clouddb: CloudDatabase - cloud: CloudController /** * The core controls controller @@ -188,7 +185,6 @@ export class Registry extends EventEmitter { this.ui = new UIController(this) this.io = this.ui.io this.db = new DataDatabase(this.appInfo.configDir) - this.clouddb = new CloudDatabase(this.appInfo.configDir) this.data = new DataController(this) this.userconfig = this.data.userconfig LogController.init(this.appInfo, this.ui.io) @@ -200,7 +196,7 @@ export class Registry extends EventEmitter { this.surfaces = new SurfaceController(this) this.instance = new InstanceController(this) this.services = new ServiceController(this) - this.cloud = new CloudController(this, this.clouddb, this.data.cache) + this.cloud = new CloudController(this, this.data.cache) this.internalModule = new InternalController(this) this.variables.values.on('variables_changed', (all_changed_variables_set) => { @@ -274,8 +270,8 @@ export class Registry extends EventEmitter { this.#logger.info('somewhere, the system wants to exit. kthxbai') // Save the db to disk - this.db.save() - this.data.cache.save() + this.db.close() + this.data.cache.close() try { this.surfaces.quit() diff --git a/companion/lib/Surface/Controller.ts b/companion/lib/Surface/Controller.ts index c81fd9d7b..59776902b 100644 --- a/companion/lib/Surface/Controller.ts +++ b/companion/lib/Surface/Controller.ts @@ -129,7 +129,7 @@ export class SurfaceController extends CoreBase { setImmediate(() => { // Setup groups - const groupsConfigs = this.db.getKey('surface-groups', {}) + const groupsConfigs = this.db.getKey('surface_groups', {}) for (const groupId of Object.keys(groupsConfigs)) { const newGroup = new SurfaceGroup( this, @@ -747,7 +747,7 @@ export class SurfaceController extends CoreBase { async reset(): Promise { // Each active handler will re-add itself when doing the save as part of its own reset this.db.setKey('deviceconfig', {}) - this.db.setKey('surface-groups', {}) + this.db.setKey('surface_groups', {}) this.#outboundController.reset() // Wait for the surfaces to disconnect before clearing their config @@ -1102,7 +1102,7 @@ export class SurfaceController extends CoreBase { } exportAllGroups(clone = true): any { - const obj = this.db.getKey('surface-groups', {}) || {} + const obj = this.db.getKey('surface_groups', {}) || {} return clone ? cloneDeep(obj) : obj } diff --git a/companion/lib/Surface/Group.ts b/companion/lib/Surface/Group.ts index 412646497..90c948cf2 100644 --- a/companion/lib/Surface/Group.ts +++ b/companion/lib/Surface/Group.ts @@ -113,7 +113,7 @@ export class SurfaceGroup { this.#isAutoGroup = true } else { - this.groupConfig = this.#db.getKey('surface-groups', {})[this.groupId] || {} + this.groupConfig = this.#db.getKey('surface_groups', {})[this.groupId] || {} } // Apply missing defaults this.groupConfig = { @@ -166,9 +166,9 @@ export class SurfaceGroup { * Delete this group from the config */ forgetConfig(): void { - const groupsConfig = this.#db.getKey('surface-groups', {}) + const groupsConfig = this.#db.getKey('surface_groups', {}) delete groupsConfig[this.groupId] - this.#db.setKey('surface-groups', groupsConfig) + this.#db.setKey('surface_groups', groupsConfig) } /** @@ -383,9 +383,9 @@ export class SurfaceGroup { const surface = this.surfaceHandlers[0] surface.saveGroupConfig(this.groupConfig) } else { - const groupsConfig = this.#db.getKey('surface-groups', {}) + const groupsConfig = this.#db.getKey('surface_groups', {}) groupsConfig[this.groupId] = this.groupConfig - this.#db.setKey('surface-groups', groupsConfig) + this.#db.setKey('surface_groups', groupsConfig) } } } diff --git a/companion/package.json b/companion/package.json index 3a9288bb8..da9ca39e0 100644 --- a/companion/package.json +++ b/companion/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@sentry/webpack-plugin": "^2.22.4", "@types/archiver": "^6.0.2", + "@types/better-sqlite3": "^7.6.11", "@types/cors": "^2.8.17", "@types/ejson": "^2.2.2", "@types/express": "^4.17.21", @@ -57,6 +58,7 @@ "@napi-rs/canvas": "^0.1.55", "@sentry/node": "^8.30.0", "archiver": "^7.0.1", + "better-sqlite3": "^11.3.0", "body-parser": "^1.20.3", "bufferutil": "^4.0.8", "colord": "^2.9.3", diff --git a/yarn.lock b/yarn.lock index 08da2080f..1fc605c13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4406,6 +4406,15 @@ __metadata: languageName: node linkType: hard +"@types/better-sqlite3@npm:^7.6.11": + version: 7.6.11 + resolution: "@types/better-sqlite3@npm:7.6.11" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/6a7b8e5765f872404242ff9626edf4b4dd7974047144ba7254a59f4f1c196d05f8001323d0b526e8bbb3842bf541e341d74ca0164e50bd38fceaf3ef5d2a673d + languageName: node + linkType: hard + "@types/body-parser@npm:*": version: 1.19.5 resolution: "@types/body-parser@npm:1.19.5" @@ -6008,6 +6017,17 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"better-sqlite3@npm:^11.3.0": + version: 11.3.0 + resolution: "better-sqlite3@npm:11.3.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10c0/9adc99683300699581da5d7288e4a261b7d4381fd99c762fc6a0e9b1e1e226009c1333b46b10c1c453c356b20cb8be037a4616b1e717b3d1a00bd8493bec506e + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.2.0 resolution: "binary-extensions@npm:2.2.0" @@ -6015,6 +6035,15 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"bindings@npm:^1.5.0": + version: 1.5.0 + resolution: "bindings@npm:1.5.0" + dependencies: + file-uri-to-path: "npm:1.0.0" + checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba + languageName: node + linkType: hard + "bl@npm:^1.2.1": version: 1.2.3 resolution: "bl@npm:1.2.3" @@ -6025,6 +6054,17 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"bl@npm:^4.0.3": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10c0/02847e1d2cb089c9dc6958add42e3cdeaf07d13f575973963335ac0fdece563a50ac770ac4c8fa06492d2dd276f6cc3b7f08c7cd9c7a7ad0f8d388b2a28def5f + languageName: node + linkType: hard + "bluebird-lst@npm:^1.0.9": version: 1.0.9 resolution: "bluebird-lst@npm:1.0.9" @@ -6165,7 +6205,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"buffer@npm:^5.1.0, buffer@npm:^5.2.1": +"buffer@npm:^5.1.0, buffer@npm:^5.2.1, buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" dependencies: @@ -6423,6 +6463,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 10c0/ed57952a84cc0c802af900cf7136de643d3aba2eecb59d29344bc2f3f9bf703a301b9d84cdc71f82c3ffc9ccde831b0d92f5b45f91727d6c9da62f23aef9d9db + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -6705,6 +6752,7 @@ asn1@evs-broadcast/node-asn1: "@sentry/node": "npm:^8.30.0" "@sentry/webpack-plugin": "npm:^2.22.4" "@types/archiver": "npm:^6.0.2" + "@types/better-sqlite3": "npm:^7.6.11" "@types/cors": "npm:^2.8.17" "@types/ejson": "npm:^2.2.2" "@types/express": "npm:^4.17.21" @@ -6716,6 +6764,7 @@ asn1@evs-broadcast/node-asn1: "@types/uuid": "npm:^10.0.0" "@types/ws": "npm:^8.5.12" archiver: "npm:^7.0.1" + better-sqlite3: "npm:^11.3.0" body-parser: "npm:^1.20.3" bufferutil: "npm:^4.0.8" colord: "npm:^2.9.3" @@ -7257,6 +7306,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"detect-libc@npm:^2.0.0": + version: 2.0.3 + resolution: "detect-libc@npm:2.0.3" + checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7 + languageName: node + linkType: hard + "detect-node@npm:^2.0.4": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -7627,7 +7683,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"end-of-stream@npm:^1.1.0": +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": version: 1.4.4 resolution: "end-of-stream@npm:1.4.4" dependencies: @@ -7990,6 +8046,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"expand-template@npm:^2.0.3": + version: 2.0.3 + resolution: "expand-template@npm:2.0.3" + checksum: 10c0/1c9e7afe9acadf9d373301d27f6a47b34e89b3391b1ef38b7471d381812537ef2457e620ae7f819d2642ce9c43b189b3583813ec395e2938319abe356a9b2f51 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -8195,6 +8258,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"file-uri-to-path@npm:1.0.0": + version: 1.0.0 + resolution: "file-uri-to-path@npm:1.0.0" + checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519 + languageName: node + linkType: hard + "filelist@npm:^1.0.4": version: 1.0.4 resolution: "filelist@npm:1.0.4" @@ -8367,6 +8437,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 10c0/a0cde99085f0872f4d244e83e03a46aa387b74f5a5af750896c6b05e9077fac00e9932fdf5aef84f2f16634cd473c63037d7a512576da7d5c2b9163d1909f3a8 + languageName: node + linkType: hard + "fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -8546,6 +8623,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"github-from-package@npm:0.0.0": + version: 0.0.0 + resolution: "github-from-package@npm:0.0.0" + checksum: 10c0/737ee3f52d0a27e26332cde85b533c21fcdc0b09fb716c3f8e522cfaa9c600d4a631dec9fcde179ec9d47cca89017b7848ed4d6ae6b6b78f936c06825b1fcc12 + languageName: node + linkType: hard + "glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -9010,7 +9094,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -10557,7 +10641,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.6": +"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.6": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 @@ -10655,6 +10739,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 10c0/95371d831d196960ddc3833cc6907e6b8f67ac5501a6582f47dfae5eb0f092e9f8ce88e0d83afcae95d6e2b61a01741ba03714eeafb6f7a6e9dcc158ac85b168 + languageName: node + linkType: hard + "mkdirp@npm:^1.0.3": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" @@ -10772,6 +10863,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"napi-build-utils@npm:^1.0.1": + version: 1.0.2 + resolution: "napi-build-utils@npm:1.0.2" + checksum: 10c0/37fd2cd0ff2ad20073ce78d83fd718a740d568b225924e753ae51cb69d68f330c80544d487e5e5bd18e28702ed2ca469c2424ad948becd1862c1b0209542b2e9 + languageName: node + linkType: hard + "negotiator@npm:0.6.3, negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" @@ -10786,6 +10884,15 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"node-abi@npm:^3.3.0": + version: 3.68.0 + resolution: "node-abi@npm:3.68.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/0f20cdb1216485ef399f581fe8fad300f1321cc66e08a7e2e7c6c6a1d89006799c464943e45dae19ec39ba581f6417dff4af21324a09c1e74a4e2fc1bceb0f83 + languageName: node + linkType: hard + "node-addon-api@npm:7.0.0": version: 7.0.0 resolution: "node-addon-api@npm:7.0.0" @@ -11526,6 +11633,28 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"prebuild-install@npm:^7.1.1": + version: 7.1.2 + resolution: "prebuild-install@npm:7.1.2" + dependencies: + detect-libc: "npm:^2.0.0" + expand-template: "npm:^2.0.3" + github-from-package: "npm:0.0.0" + minimist: "npm:^1.2.3" + mkdirp-classic: "npm:^0.5.3" + napi-build-utils: "npm:^1.0.1" + node-abi: "npm:^3.3.0" + pump: "npm:^3.0.0" + rc: "npm:^1.2.7" + simple-get: "npm:^4.0.0" + tar-fs: "npm:^2.0.0" + tunnel-agent: "npm:^0.6.0" + bin: + prebuild-install: bin.js + checksum: 10c0/e64868ba9ef2068fd7264f5b03e5298a901e02a450acdb1f56258d88c09dea601eefdb3d1dfdff8513fdd230a92961712be0676192626a3b4d01ba154d48bdd3 + languageName: node + linkType: hard + "prettier@npm:^3.3.3": version: 3.3.3 resolution: "prettier@npm:3.3.3" @@ -12107,7 +12236,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -12850,6 +12979,24 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"simple-concat@npm:^1.0.0": + version: 1.0.1 + resolution: "simple-concat@npm:1.0.1" + checksum: 10c0/62f7508e674414008910b5397c1811941d457dfa0db4fd5aa7fa0409eb02c3609608dfcd7508cace75b3a0bf67a2a77990711e32cd213d2c76f4fd12ee86d776 + languageName: node + linkType: hard + +"simple-get@npm:^4.0.0": + version: 4.0.1 + resolution: "simple-get@npm:4.0.1" + dependencies: + decompress-response: "npm:^6.0.0" + once: "npm:^1.3.1" + simple-concat: "npm:^1.0.0" + checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0 + languageName: node + linkType: hard + "simple-swizzle@npm:^0.2.2": version: 0.2.2 resolution: "simple-swizzle@npm:0.2.2" @@ -13379,6 +13526,31 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"tar-fs@npm:^2.0.0": + version: 2.1.1 + resolution: "tar-fs@npm:2.1.1" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 10c0/871d26a934bfb7beeae4c4d8a09689f530b565f79bd0cf489823ff0efa3705da01278160da10bb006d1a793fa0425cf316cec029b32a9159eacbeaff4965fb6d + languageName: node + linkType: hard + +"tar-stream@npm:^2.1.4": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 10c0/2f4c910b3ee7196502e1ff015a7ba321ec6ea837667220d7bcb8d0852d51cb04b87f7ae471008a6fb8f5b1a1b5078f62f3a82d30c706f20ada1238ac797e7692 + languageName: node + linkType: hard + "tar-stream@npm:^3.0.0": version: 3.1.7 resolution: "tar-stream@npm:3.1.7" @@ -13673,6 +13845,15 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"tunnel-agent@npm:^0.6.0": + version: 0.6.0 + resolution: "tunnel-agent@npm:0.6.0" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/4c7a1b813e7beae66fdbf567a65ec6d46313643753d0beefb3c7973d66fcec3a1e7f39759f0a0b4465883499c6dc8b0750ab8b287399af2e583823e40410a17a + languageName: node + linkType: hard + "type-fest@npm:^0.13.1": version: 0.13.1 resolution: "type-fest@npm:0.13.1"