3 const common
= require('../../common');
4 const Database
= require('../base');
5 const DBErrors
= require('../errors');
6 const svh
= require('../schema-version-helper');
7 const SQLite
= require('better-sqlite3');
8 const fs
= require('fs');
9 const path
= require('path');
10 const { performance
} = require('perf_hooks');
11 const uuid
= require('uuid');
13 const _fileScope
= common
.fileScope(__filename
);
15 const schemaVersionsSupported
= {
28 // max of signed int64 (2^63 - 1), should be enough
29 // const EPOCH_FOREVER = BigInt('9223372036854775807');
31 class DatabaseSQLite
extends Database
{
32 constructor(logger
, options
) {
33 super(logger
, options
);
35 const connectionString
= options
.db
.connectionString
|| 'sqlite://:memory:';
36 const csDelim
= '://';
37 const dbFilename
= connectionString
.slice(connectionString
.indexOf(csDelim
) + csDelim
.length
);
39 const queryLogLevel
= options
.db
.queryLogLevel
;
41 const sqliteOptions
= {
42 ...(queryLogLevel
&& {
43 // eslint-disable-next-line security/detect-object-injection
44 verbose: (query
) => this.logger
[queryLogLevel
](_fileScope('SQLite:verbose'), '', { query
}),
47 this.db
= new SQLite(dbFilename
, sqliteOptions
);
48 this.schemaVersionsSupported
= schemaVersionsSupported
;
49 this.changesSinceLastOptimize
= BigInt(0);
50 this.optimizeAfterChanges
= options
.db
.connectionString
.optimizeAfterChanges
;
51 this.db
.pragma('foreign_keys = on'); // Enforce consistency.
52 this.db
.pragma('journal_mode = WAL'); // Be faster, expect local filesystem.
53 this.db
.defaultSafeIntegers(true); // This probably isn't necessary, but by using these BigInts we keep weird floats out of the query logs.
56 this._initStatements();
61 * SQLite cannot prepare its statements without a schema, ensure such exists.
64 const _scope
= _fileScope('_initTables');
66 // Migrations rely upon this table, ensure it exists.
67 const metaVersionTable
= '_meta_schema_version';
68 const tableExists
= this.db
.prepare('SELECT name FROM sqlite_master WHERE type=:type AND name=:name').pluck(true).bind({ type: 'table', name: metaVersionTable
});
69 let metaExists
= tableExists
.get();
70 if (metaExists
=== undefined) {
71 const fPath
= path
.join(__dirname
, 'sql', 'schema', 'init.sql');
72 // eslint-disable-next-line security/detect-non-literal-fs-filename
73 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
75 metaExists
= tableExists
.get();
76 /* istanbul ignore if */
77 if (metaExists
=== undefined) {
78 throw new DBErrors
.UnexpectedResult(`did not create ${metaVersionTable} table`);
80 this.logger
.info(_scope
, 'created schema version table', { metaVersionTable
});
84 const currentSchema
= this._currentSchema();
85 const migrationsWanted
= svh
.unappliedSchemaVersions(__dirname
, currentSchema
, this.schemaVersionsSupported
);
86 this.logger
.debug(_scope
, 'schema migrations wanted', { migrationsWanted
});
87 migrationsWanted
.forEach((v
) => {
88 const fPath
= path
.join(__dirname
, 'sql', 'schema', v
, 'apply.sql');
89 // eslint-disable-next-line security/detect-non-literal-fs-filename
90 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
91 this.logger
.info(_scope
, 'applying migration', { version: v
});
98 const _scope
= _fileScope('_initStatements');
99 const sqlDir
= path
.join(__dirname
, 'sql');
102 // Decorate the statement calls we use with timing and logging.
103 const wrapFetch
= (logName
, statementName
, fn
) => {
104 const _wrapScope
= _fileScope(logName
);
105 return (...args
) => {
106 const startTimestampMs
= performance
.now();
107 const rows
= fn(...args
);
108 DatabaseSQLite
._deOphidiate(rows
);
109 const elapsedTimeMs
= performance
.now() - startTimestampMs
;
110 this.logger
.debug(_wrapScope
, 'complete', { statementName
, elapsedTimeMs
});
114 const wrapRun
= (logName
, statementName
, fn
) => {
115 const _wrapScope
= _fileScope(logName
);
116 return (...args
) => {
117 const startTimestampMs
= performance
.now();
118 const result
= fn(...args
);
119 const elapsedTimeMs
= performance
.now() - startTimestampMs
;
120 this.logger
.debug(_wrapScope
, 'complete', { ...result
, statementName
, elapsedTimeMs
});
121 result
.duration
= elapsedTimeMs
;
126 // eslint-disable-next-line security/detect-non-literal-fs-filename
127 for (const f
of fs
.readdirSync(sqlDir
)) {
128 const fPath
= path
.join(sqlDir
, f
);
129 const { name: fName
, ext: fExt
} = path
.parse(f
);
130 // eslint-disable-next-line security/detect-non-literal-fs-filename
131 const stat
= fs
.statSync(fPath
);
133 || fExt
.toLowerCase() !== '.sql') {
136 // eslint-disable-next-line security/detect-non-literal-fs-filename
137 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
138 const statementName
= Database
._camelfy(fName
.toLowerCase(), '-');
141 statement
= this.db
.prepare(fSql
);
143 /* istanbul ignore next */
144 this.logger
.error(_scope
, 'failed to prepare statement', { error: e
, file: f
});
145 /* istanbul ignore next */
148 // eslint-disable-next-line security/detect-object-injection
149 this.statement
[statementName
] = statement
;
150 const { get: origGet
, all: origAll
, run: origRun
} = statement
;
151 statement
.get = wrapFetch('SQLite:get', statementName
, origGet
.bind(statement
));
152 statement
.all
= wrapFetch('SQLite:all', statementName
, origAll
.bind(statement
));
153 statement
.run
= wrapRun('SQLite:run', statementName
, origRun
.bind(statement
));
155 this.statement
._optimize
= this.db
.prepare('SELECT * FROM pragma_optimize(0x03)');
157 this.logger
.debug(_scope
, 'statements initialized', { statements: Object
.keys(this.statement
).length
});
161 static _deOphidiate(rows
) {
162 const rowsIsArray
= Array
.isArray(rows
);
166 const exemplaryRow
= rows
[0];
167 for (const prop
in exemplaryRow
) {
168 const camel
= Database
._camelfy(prop
);
169 if (!(camel
in exemplaryRow
)) {
170 for (const d
of rows
) {
171 // eslint-disable-next-line security/detect-object-injection
173 // eslint-disable-next-line security/detect-object-injection
178 return rowsIsArray
? rows : rows
[0];
183 return this.db
.prepare('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1').get();
188 const _scope
= _fileScope('healthCheck');
189 this.logger
.debug(_scope
, 'called', {});
191 throw new DBErrors
.UnexpectedResult('database is not open');
193 return { open: this.db
.open
};
203 const _scope
= _fileScope('_optimize');
205 if (this.optimizeAfterChanges
206 && this.changesSinceLastOptimize
>= this.optimizeAfterChanges
) {
207 const optimize
= this.statement
._optimize
.all();
208 this.logger
.debug(_scope
, 'optimize', { optimize
});
209 this.db
.pragma('optimize');
210 this.changesSinceLastOptimize
= BigInt(0);
215 _purgeTables(really
) {
221 const result
= this.db
.prepare(`DELETE FROM ${table}`).run();
222 this.logger
.debug(_fileScope('_purgeTables'), 'success', { table
, result
});
233 transaction(dbCtx
, fn
) {
234 dbCtx
= dbCtx
|| this.db
;
235 return dbCtx
.transaction(fn
)();
239 static _dateFromEpoch(epoch
) {
240 return new Date(Number(epoch
* 1000n
));
244 static _accountToNative(account
) {
246 created: DatabaseSQLite
._dateFromEpoch(account
.created
),
247 accountId: Number(account
.accountId
),
248 profile: Database
._URLFromParts({
249 protocol: account
.profile_protocol
,
250 username: account
.profile_username
,
251 password: account
.profile_password
,
252 host: account
.profile_host
,
253 pathname: account
.profile_pathname
,
254 search: account
.profile_search
,
255 hash: account
.profile_hash
,
262 accountGetByProfile(dbCtx
, profile
) {
263 const _scope
= _fileScope('accountGetByProfile');
264 this.logger
.debug(_scope
, 'called', { profile
});
268 protocol: profileProtocol
,
269 username: profileUsername
,
270 password: profilePassword
,
272 pathname: profilePathname
,
273 search: profileSearch
,
275 } = new URL(profile
);
276 const account
= this.statement
.accountGetByProfile
.get({
285 return DatabaseSQLite
._accountToNative(account
);
287 this.logger
.error(_scope
, 'failed', { error: e
, profile
});
293 accountGetByProfilePrefix(dbCtx
, targetURL
) {
294 const _scope
= _fileScope('accountGetByProfilePrefix');
295 this.logger
.debug(_scope
, 'called', { targetURL
});
300 this.logger
.error(_scope
, 'failed', { error: e
, targetURL
});
305 accountInsert(dbCtx
, profile
) {
306 const _scope
= _fileScope('authenticationUpsert');
307 this.logger
.debug(_scope
, 'called', { profile
});
310 const accountId
= uuid
.v4();
311 const result
= this.statement
.accountInsert
.run({ accountId
, profile
});
312 if (result
.changes
!= 1) {
313 throw new DBErrors
.UnexpectedResult('did not insert account');
316 this.logger
.error(_scope
, 'failed', { error: e
, profile
})
326 module
.exports
= DatabaseSQLite
;