2021-05-13 18:57:37 +02:00
|
|
|
import { knex, Knex } from 'knex';
|
2020-12-28 11:48:47 +00:00
|
|
|
import { DatabaseConfig } from './utils/types';
|
|
|
|
import * as pathUtils from 'path';
|
|
|
|
import time from '@joplin/lib/time';
|
|
|
|
import Logger from '@joplin/lib/Logger';
|
2021-08-22 11:43:41 +01:00
|
|
|
import { databaseSchema } from './services/database/types';
|
2020-12-28 11:48:47 +00:00
|
|
|
|
|
|
|
// Make sure bigInteger values are numbers and not strings
|
|
|
|
//
|
|
|
|
// https://github.com/brianc/node-pg-types
|
|
|
|
//
|
|
|
|
// In our case, all bigInteger are timestamps, which JavaScript can handle
|
|
|
|
// fine as numbers.
|
|
|
|
require('pg').types.setTypeParser(20, function(val: any) {
|
|
|
|
return parseInt(val, 10);
|
|
|
|
});
|
|
|
|
|
|
|
|
const logger = Logger.create('db');
|
|
|
|
|
2021-08-18 12:47:05 +01:00
|
|
|
// To prevent error "SQLITE_ERROR: too many SQL variables", SQL statements with
|
|
|
|
// "IN" clauses shouldn't contain more than the number of variables below.s
|
|
|
|
// https://www.sqlite.org/limits.html#max_variable_number
|
|
|
|
export const SqliteMaxVariableNum = 999;
|
|
|
|
|
2020-12-28 11:48:47 +00:00
|
|
|
const migrationDir = `${__dirname}/migrations`;
|
2021-05-25 12:13:35 +02:00
|
|
|
export const sqliteDefaultDir = pathUtils.dirname(__dirname);
|
2020-12-28 11:48:47 +00:00
|
|
|
|
2020-12-30 18:35:18 +00:00
|
|
|
export const defaultAdminEmail = 'admin@localhost';
|
|
|
|
export const defaultAdminPassword = 'admin';
|
|
|
|
|
2020-12-28 11:48:47 +00:00
|
|
|
export type DbConnection = Knex;
|
|
|
|
|
|
|
|
export interface DbConfigConnection {
|
|
|
|
host?: string;
|
|
|
|
port?: number;
|
|
|
|
user?: string;
|
|
|
|
database?: string;
|
|
|
|
filename?: string;
|
|
|
|
password?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface KnexDatabaseConfig {
|
|
|
|
client: string;
|
|
|
|
connection: DbConfigConnection;
|
|
|
|
useNullAsDefault?: boolean;
|
|
|
|
asyncStackTraces?: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface ConnectionCheckResult {
|
|
|
|
isCreated: boolean;
|
|
|
|
error: any;
|
|
|
|
latestMigration: any;
|
|
|
|
connection: DbConnection;
|
|
|
|
}
|
|
|
|
|
2021-08-22 11:28:15 +01:00
|
|
|
export interface Migration {
|
|
|
|
name: string;
|
|
|
|
done: boolean;
|
|
|
|
}
|
|
|
|
|
2020-12-28 11:48:47 +00:00
|
|
|
export function makeKnexConfig(dbConfig: DatabaseConfig): KnexDatabaseConfig {
|
|
|
|
const connection: DbConfigConnection = {};
|
|
|
|
|
|
|
|
if (dbConfig.client === 'sqlite3') {
|
2021-05-25 12:13:35 +02:00
|
|
|
connection.filename = dbConfig.name;
|
2020-12-28 11:48:47 +00:00
|
|
|
} else {
|
|
|
|
connection.database = dbConfig.name;
|
|
|
|
connection.host = dbConfig.host;
|
|
|
|
connection.port = dbConfig.port;
|
|
|
|
connection.user = dbConfig.user;
|
|
|
|
connection.password = dbConfig.password;
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
client: dbConfig.client,
|
|
|
|
useNullAsDefault: dbConfig.client === 'sqlite3',
|
|
|
|
asyncStackTraces: dbConfig.asyncStackTraces,
|
|
|
|
connection,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function waitForConnection(dbConfig: DatabaseConfig): Promise<ConnectionCheckResult> {
|
|
|
|
const timeout = 30000;
|
|
|
|
const startTime = Date.now();
|
|
|
|
let lastError = { message: '' };
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
try {
|
|
|
|
const connection = await connectDb(dbConfig);
|
|
|
|
const check = await connectionCheck(connection);
|
|
|
|
if (check.error) throw check.error;
|
|
|
|
return check;
|
|
|
|
} catch (error) {
|
|
|
|
logger.info('Could not connect. Will try again.', error.message);
|
|
|
|
lastError = error;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (Date.now() - startTime > timeout) {
|
|
|
|
logger.error('Timeout trying to connect to database:', lastError);
|
|
|
|
throw new Error(`Timeout trying to connect to database. Last error was: ${lastError.message}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
await time.msleep(1000);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> {
|
2021-06-17 16:51:25 +01:00
|
|
|
const connection = knex(makeKnexConfig(dbConfig));
|
|
|
|
|
|
|
|
const debugSlowQueries = false;
|
|
|
|
|
|
|
|
if (debugSlowQueries) {
|
|
|
|
const startTimes: Record<string, number> = {};
|
|
|
|
|
|
|
|
const slowQueryDuration = 10;
|
|
|
|
|
|
|
|
connection.on('query', (data) => {
|
|
|
|
startTimes[data.__knexQueryUid] = Date.now();
|
|
|
|
});
|
|
|
|
|
|
|
|
connection.on('query-response', (_response, data) => {
|
|
|
|
const duration = Date.now() - startTimes[data.__knexQueryUid];
|
|
|
|
if (duration < slowQueryDuration) return;
|
|
|
|
console.info(`SQL: ${data.sql} (${duration}ms)`);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
return connection;
|
2020-12-28 11:48:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export async function disconnectDb(db: DbConnection) {
|
|
|
|
await db.destroy();
|
|
|
|
}
|
|
|
|
|
2021-08-14 17:49:01 +01:00
|
|
|
export async function migrateLatest(db: DbConnection) {
|
2020-12-28 11:48:47 +00:00
|
|
|
await db.migrate.latest({
|
|
|
|
directory: migrationDir,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-08-14 17:49:01 +01:00
|
|
|
export async function migrateUp(db: DbConnection) {
|
|
|
|
await db.migrate.up({
|
|
|
|
directory: migrationDir,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function migrateDown(db: DbConnection) {
|
|
|
|
await db.migrate.down({
|
|
|
|
directory: migrationDir,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function migrateList(db: DbConnection, asString: boolean = true) {
|
|
|
|
const migrations: any = await db.migrate.list({
|
|
|
|
directory: migrationDir,
|
|
|
|
});
|
|
|
|
|
|
|
|
// The migration array has a rather inconsistent format:
|
|
|
|
//
|
|
|
|
// [
|
|
|
|
// // Done migrations
|
|
|
|
// [
|
|
|
|
// '20210809222118_email_key_fix.js',
|
|
|
|
// '20210814123815_testing.js',
|
|
|
|
// '20210814123816_testing.js'
|
|
|
|
// ],
|
|
|
|
// // Not done migrations
|
|
|
|
// [
|
|
|
|
// {
|
|
|
|
// file: '20210814123817_testing.js',
|
|
|
|
// directory: '/path/to/packages/server/dist/migrations'
|
|
|
|
// }
|
|
|
|
// ]
|
|
|
|
// ]
|
|
|
|
|
|
|
|
const formatName = (migrationInfo: any) => {
|
|
|
|
const name = migrationInfo.file ? migrationInfo.file : migrationInfo;
|
|
|
|
|
|
|
|
const s = name.split('.');
|
|
|
|
s.pop();
|
|
|
|
return s.join('.');
|
|
|
|
};
|
|
|
|
|
2021-08-22 11:28:15 +01:00
|
|
|
const output: Migration[] = [];
|
2021-08-14 17:49:01 +01:00
|
|
|
|
|
|
|
for (const s of migrations[0]) {
|
|
|
|
output.push({
|
2021-08-22 11:28:15 +01:00
|
|
|
name: formatName(s),
|
2021-08-14 17:49:01 +01:00
|
|
|
done: true,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const s of migrations[1]) {
|
|
|
|
output.push({
|
2021-08-22 11:28:15 +01:00
|
|
|
name: formatName(s),
|
2021-08-14 17:49:01 +01:00
|
|
|
done: false,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
output.sort((a, b) => {
|
2021-08-22 11:28:15 +01:00
|
|
|
return a.name < b.name ? -1 : +1;
|
2021-08-14 17:49:01 +01:00
|
|
|
});
|
|
|
|
|
2021-08-22 11:28:15 +01:00
|
|
|
if (!asString) return output;
|
|
|
|
|
|
|
|
return output.map(l => `${l.done ? '✓' : '✗'} ${l.name}`).join('\n');
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function nextMigration(db: DbConnection): Promise<string> {
|
|
|
|
const list = await migrateList(db, false) as Migration[];
|
|
|
|
|
|
|
|
let nextMigration: Migration = null;
|
|
|
|
|
|
|
|
while (list.length) {
|
|
|
|
const migration = list.pop();
|
|
|
|
if (migration.done) return nextMigration ? nextMigration.name : '';
|
|
|
|
nextMigration = migration;
|
|
|
|
}
|
|
|
|
|
|
|
|
return '';
|
2021-08-14 17:49:01 +01:00
|
|
|
}
|
|
|
|
|
2020-12-28 11:48:47 +00:00
|
|
|
function allTableNames(): string[] {
|
|
|
|
const tableNames = Object.keys(databaseSchema);
|
|
|
|
tableNames.push('knex_migrations');
|
|
|
|
tableNames.push('knex_migrations_lock');
|
|
|
|
return tableNames;
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function dropTables(db: DbConnection): Promise<void> {
|
|
|
|
for (const tableName of allTableNames()) {
|
|
|
|
try {
|
|
|
|
await db.schema.dropTable(tableName);
|
|
|
|
} catch (error) {
|
|
|
|
if (isNoSuchTableError(error)) continue;
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-12-30 18:35:18 +00:00
|
|
|
export async function truncateTables(db: DbConnection): Promise<void> {
|
|
|
|
for (const tableName of allTableNames()) {
|
|
|
|
try {
|
|
|
|
await db(tableName).truncate();
|
|
|
|
} catch (error) {
|
|
|
|
if (isNoSuchTableError(error)) continue;
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-12-28 11:48:47 +00:00
|
|
|
function isNoSuchTableError(error: any): boolean {
|
|
|
|
if (error) {
|
|
|
|
// Postgres error: 42P01: undefined_table
|
|
|
|
if (error.code === '42P01') return true;
|
|
|
|
|
|
|
|
// Sqlite3 error
|
2021-05-13 18:57:37 +02:00
|
|
|
if (error.message && error.message.includes('SQLITE_ERROR: no such table:')) return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function isUniqueConstraintError(error: any): boolean {
|
|
|
|
if (error) {
|
|
|
|
// Postgres error: 23505: unique_violation
|
|
|
|
if (error.code === '23505') return true;
|
|
|
|
|
|
|
|
// Sqlite3 error
|
|
|
|
if (error.code === 'SQLITE_CONSTRAINT' && error.message.includes('UNIQUE constraint')) return true;
|
2020-12-28 11:48:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function latestMigration(db: DbConnection): Promise<any> {
|
|
|
|
try {
|
|
|
|
const result = await db('knex_migrations').select('name').orderBy('id', 'asc').first();
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
|
|
// If the database has never been initialized, we return null, so
|
|
|
|
// for this we need to check the error code, which will be
|
|
|
|
// different depending on the DBMS.
|
|
|
|
|
|
|
|
if (isNoSuchTableError(error)) return null;
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function connectionCheck(db: DbConnection): Promise<ConnectionCheckResult> {
|
|
|
|
try {
|
|
|
|
const result = await latestMigration(db);
|
|
|
|
return {
|
|
|
|
latestMigration: result,
|
|
|
|
isCreated: !!result,
|
|
|
|
error: null,
|
|
|
|
connection: db,
|
|
|
|
};
|
|
|
|
} catch (error) {
|
|
|
|
return {
|
|
|
|
latestMigration: null,
|
|
|
|
isCreated: false,
|
|
|
|
error: error,
|
|
|
|
connection: null,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|