// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { Database } from 'better-sqlite3'; import { isNumber, last } from 'lodash'; export type EmptyQuery = []; export type ArrayQuery = Array>; export type Query = { [key: string]: null | number | bigint | string | Uint8Array; }; export type JSONRows = Array<{ readonly json: string }>; export type TableType = | 'attachment_downloads' | 'conversations' | 'identityKeys' | 'items' | 'messages' | 'preKeys' | 'senderKeys' | 'sessions' | 'signedPreKeys' | 'stickers' | 'unprocessed'; // This value needs to be below SQLITE_MAX_VARIABLE_NUMBER. const MAX_VARIABLE_COUNT = 100; export function objectToJSON(data: T): string { return JSON.stringify(data); } export function jsonToObject(json: string): T { return JSON.parse(json); } // // Database helpers // export function getSQLiteVersion(db: Database): string { const { sqlite_version: version } = db .prepare('select sqlite_version() AS sqlite_version') .get(); return version; } export function getSchemaVersion(db: Database): number { return db.pragma('schema_version', { simple: true }); } export function setUserVersion(db: Database, version: number): void { if (!isNumber(version)) { throw new Error(`setUserVersion: version ${version} is not a number`); } db.pragma(`user_version = ${version}`); } export function getUserVersion(db: Database): number { return db.pragma('user_version', { simple: true }); } export function getSQLCipherVersion(db: Database): string | undefined { return db.pragma('cipher_version', { simple: true }); } // // Various table helpers // export function batchMultiVarQuery( db: Database, values: Array, query: (batch: Array) => void ): []; export function batchMultiVarQuery( db: Database, values: Array, query: (batch: Array) => Array ): Array; export function batchMultiVarQuery( db: Database, values: Array, query: | ((batch: Array) => void) | ((batch: Array) => Array) ): Array { if (values.length > MAX_VARIABLE_COUNT) { const result: Array = []; db.transaction(() => { for (let i = 0; i < values.length; i += MAX_VARIABLE_COUNT) { const batch = values.slice(i, i + MAX_VARIABLE_COUNT); const batchResult = query(batch); if (Array.isArray(batchResult)) { result.push(...batchResult); } } })(); return result; } const result = query(values); return Array.isArray(result) ? result : []; } export function createOrUpdate( db: Database, table: TableType, data: Record & { id: Key } ): void { const { id } = data; if (!id) { throw new Error('createOrUpdate: Provided data did not have a truthy id'); } db.prepare( ` INSERT OR REPLACE INTO ${table} ( id, json ) values ( $id, $json ) ` ).run({ id, json: objectToJSON(data), }); } export function bulkAdd( db: Database, table: TableType, array: Array & { id: string | number }> ): void { db.transaction(() => { for (const data of array) { createOrUpdate(db, table, data); } })(); } export function getById( db: Database, table: TableType, id: Key ): Result | undefined { const row = db .prepare( ` SELECT * FROM ${table} WHERE id = $id; ` ) .get({ id, }); if (!row) { return undefined; } return jsonToObject(row.json); } export function removeById( db: Database, table: TableType, id: Key | Array ): void { if (!Array.isArray(id)) { db.prepare( ` DELETE FROM ${table} WHERE id = $id; ` ).run({ id }); return; } if (!id.length) { throw new Error('removeById: No ids to delete!'); } const removeByIdsSync = (ids: Array): void => { db.prepare( ` DELETE FROM ${table} WHERE id IN ( ${id.map(() => '?').join(', ')} ); ` ).run(ids); }; batchMultiVarQuery(db, id, removeByIdsSync); } export function removeAllFromTable(db: Database, table: TableType): void { db.prepare(`DELETE FROM ${table};`).run(); } export function getAllFromTable(db: Database, table: TableType): Array { const rows: JSONRows = db .prepare(`SELECT json FROM ${table};`) .all(); return rows.map(row => jsonToObject(row.json)); } export function getCountFromTable(db: Database, table: TableType): number { const result: null | number = db .prepare(`SELECT count(*) from ${table};`) .pluck(true) .get(); if (isNumber(result)) { return result; } throw new Error(`getCountFromTable: Unable to get count from table ${table}`); } export class TableIterator { constructor( private readonly db: Database, private readonly table: TableType, private readonly pageSize = 500 ) {} *[Symbol.iterator](): Iterator { const fetchObject = this.db.prepare( ` SELECT json FROM ${this.table} WHERE id > $id ORDER BY id ASC LIMIT $pageSize; ` ); let complete = false; let id = ''; while (!complete) { const rows: JSONRows = fetchObject.all({ id, pageSize: this.pageSize, }); const messages: Array = rows.map(row => jsonToObject(row.json) ); yield* messages; const lastMessage: ObjectType | undefined = last(messages); if (lastMessage) { ({ id } = lastMessage); } complete = messages.length < this.pageSize; } } }