mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
currentDBTime and lastBackupTime became Date objects when the backup was made asynchronous in 2014, so comparing them with == has tested object identity and returned false ever since. The interval check that follows measures from the backup file's mtime, which a copy inherits from the database file, so an unchanged database was backed up on every idle. Fixes #6027
2622 lines
79 KiB
JavaScript
2622 lines
79 KiB
JavaScript
/*
|
||
***** BEGIN LICENSE BLOCK *****
|
||
|
||
Copyright © 2009 Center for History and New Media
|
||
George Mason University, Fairfax, Virginia, USA
|
||
http://zotero.org
|
||
|
||
This file is part of Zotero.
|
||
|
||
Zotero is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as published by
|
||
the Free Software Foundation, either version 3 of the License, or
|
||
(at your option) any later version.
|
||
|
||
Zotero is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||
|
||
***** END LICENSE BLOCK *****
|
||
*/
|
||
|
||
"use strict";
|
||
|
||
// Exclusive locking mode (default) prevents access to Zotero database while Zotero is open
|
||
// and speeds up DB access (http://www.sqlite.org/pragma.html#pragma_locking_mode).
|
||
// Normal mode is more convenient for development, but risks database corruption, particularly if
|
||
// the same database is accessed simultaneously by multiple Zotero instances.
|
||
const DB_LOCK_EXCLUSIVE = true;
|
||
|
||
Zotero.DBConnection = function (dbNameOrPath) {
|
||
if (!dbNameOrPath) {
|
||
throw ('DB name not provided in Zotero.DBConnection()');
|
||
}
|
||
|
||
this.MAX_BOUND_PARAMETERS = 999;
|
||
this.DB_CORRUPTION_STRINGS = [
|
||
"database disk image is malformed",
|
||
"2152857611"
|
||
];
|
||
|
||
ChromeUtils.defineESModuleGetters(this, {
|
||
Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
|
||
});
|
||
|
||
this.closed = false;
|
||
this.skipBackup = false;
|
||
|
||
// JS Date
|
||
this.__defineGetter__('transactionDate', function () {
|
||
if (this._transactionDate) {
|
||
this._lastTransactionDate = this._transactionDate;
|
||
return this._transactionDate;
|
||
}
|
||
|
||
throw new Error("Transaction not in progress");
|
||
|
||
// Use second granularity rather than millisecond
|
||
// for comparison purposes
|
||
var d = new Date(Math.floor(new Date / 1000) * 1000);
|
||
this._lastTransactionDate = d;
|
||
return d;
|
||
});
|
||
// SQL DATETIME
|
||
this.__defineGetter__('transactionDateTime', function () {
|
||
var d = this.transactionDate;
|
||
return Zotero.Date.dateToSQL(d, true);
|
||
});
|
||
// Unix timestamp
|
||
this.__defineGetter__('transactionTimestamp', function () {
|
||
var d = this.transactionDate;
|
||
return Zotero.Date.toUnixTimestamp(d);
|
||
});
|
||
|
||
// Absolute path to DB
|
||
if (dbNameOrPath.startsWith('/') || (Zotero.isWin && dbNameOrPath.includes('\\'))) {
|
||
this._dbName = PathUtils.filename(dbNameOrPath).replace(/\.sqlite$/, '');
|
||
this._dbPath = dbNameOrPath;
|
||
this._externalDB = true;
|
||
}
|
||
// DB name in data directory
|
||
else {
|
||
this._dbName = dbNameOrPath;
|
||
this._dbPath = Zotero.DataDirectory.getDatabase(dbNameOrPath);
|
||
this._externalDB = false;
|
||
}
|
||
this._shutdown = false;
|
||
this._connection = null;
|
||
this._transactionID = null;
|
||
this._transactionDate = null;
|
||
this._lastTransactionDate = null;
|
||
this._transactionRollback = false;
|
||
this._transactionNestingLevel = 0;
|
||
this._commitCount = 0;
|
||
this._callbacks = {
|
||
begin: [],
|
||
commit: [],
|
||
rollback: [],
|
||
current: {
|
||
commit: [],
|
||
rollback: []
|
||
}
|
||
};
|
||
this._dbIsCorrupt = null
|
||
this._checkingCorruption = false;
|
||
this._handlingCorruption = false;
|
||
this._corruptionHandlers = [];
|
||
this._idleCallbacks = [];
|
||
this._onlineBackupInProgress = false;
|
||
this._onConnectCallbacks = [];
|
||
this._loadedExtensions = new Set();
|
||
|
||
this._transactionPromise = null;
|
||
|
||
if (dbNameOrPath == 'zotero') {
|
||
this.IncompatibleVersionException = function (msg, dbClientVersion) {
|
||
this.message = msg;
|
||
this.dbClientVersion = dbClientVersion;
|
||
}
|
||
this.IncompatibleVersionException.prototype = Object.create(Error.prototype);
|
||
}
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////
|
||
//
|
||
// Public methods
|
||
//
|
||
/////////////////////////////////////////////////////////////////
|
||
Zotero.defineProperty(Zotero.DBConnection.prototype, 'path', {
|
||
get: function () {
|
||
return this._dbPath;
|
||
}
|
||
});
|
||
|
||
|
||
/**
|
||
* Test a read-only connection to the database, throwing any errors that occur
|
||
*
|
||
* @return void
|
||
*/
|
||
Zotero.DBConnection.prototype.test = function () {
|
||
return this._getConnectionAsync().then(() => {});
|
||
}
|
||
|
||
|
||
Zotero.DBConnection.prototype.parseQueryAndParams = function (sql, params) {
|
||
// If single scalar value, wrap in an array
|
||
if (!Array.isArray(params)) {
|
||
if (typeof params == 'string' || typeof params == 'number' || typeof params == 'object'
|
||
|| params === null) {
|
||
params = [params];
|
||
}
|
||
else {
|
||
params = [];
|
||
}
|
||
}
|
||
// Otherwise, since we might make changes, only work on a copy of the array
|
||
else {
|
||
params = params.concat();
|
||
}
|
||
|
||
// Find placeholders
|
||
if (params.length) {
|
||
let matches = sql.match(/\?\d*/g);
|
||
if (!matches) {
|
||
throw new Error("Parameters provided for query without placeholders "
|
||
+ "[QUERY: " + sql + "]");
|
||
}
|
||
else {
|
||
// Count numbered parameters (?1) properly
|
||
let num = 0;
|
||
let numbered = {};
|
||
for (let i = 0; i < matches.length; i++) {
|
||
let match = matches[i];
|
||
if (match == '?') {
|
||
num++;
|
||
}
|
||
else {
|
||
numbered[match] = true;
|
||
}
|
||
}
|
||
num += Object.keys(numbered).length;
|
||
|
||
if (params.length != num) {
|
||
throw new Error("Incorrect number of parameters provided for query "
|
||
+ "(" + params.length + ", expecting " + num + ") "
|
||
+ "[QUERY: " + sql + "]");
|
||
}
|
||
}
|
||
|
||
// First, determine the type of query using first word
|
||
let queryMethod = sql.match(/^[^\s\(]*/)[0].toLowerCase();
|
||
|
||
// Reset lastIndex, since regexp isn't recompiled dynamically
|
||
let placeholderRE = /\s*[=,(]\s*\?/g;
|
||
for (var i=0; i<params.length; i++) {
|
||
// Find index of this parameter, skipping previous ones
|
||
matches = placeholderRE.exec(sql);
|
||
|
||
if (typeof params[i] == 'boolean') {
|
||
throw new Error("Invalid boolean parameter " + i + " '" + params[i] + "' "
|
||
+ "[QUERY: " + sql + "]");
|
||
}
|
||
else if (params[i] === undefined) {
|
||
throw new Error('Parameter ' + i + ' is undefined [QUERY: ' + sql + ']');
|
||
}
|
||
|
||
if (params[i] !== null) {
|
||
// Force parameter type if specified
|
||
|
||
// Int
|
||
if (typeof params[i]['int'] != 'undefined') {
|
||
params[i] = parseInt(params[i]['int']);
|
||
if (isNaN(params[i])) {
|
||
throw new Error("Invalid bound parameter " + i + " integer value '" + params[i] + "' "
|
||
+ "[QUERY: " + sql + "]")
|
||
}
|
||
}
|
||
// String
|
||
else if (typeof params[i]['string'] != 'undefined') {
|
||
params[i] = params[i]['string'] + "";
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
//
|
||
// Replace NULL bound parameters with hard-coded NULLs
|
||
//
|
||
if (!matches) {
|
||
throw new Error("Null parameter provided for a query without placeholders "
|
||
+ "-- use false or undefined [QUERY: " + sql + "]");
|
||
}
|
||
|
||
if (matches[0].trim().indexOf('=') == -1) {
|
||
if (queryMethod == 'select') {
|
||
throw new Error("NULL cannot be used for parenthesized placeholders "
|
||
+ "in SELECT queries [QUERY: " + sql + "]");
|
||
}
|
||
var repl = matches[0].replace('?', 'NULL');
|
||
}
|
||
else if (queryMethod == 'select') {
|
||
var repl = ' IS NULL';
|
||
}
|
||
else {
|
||
var repl = '=NULL';
|
||
}
|
||
|
||
var subpos = matches.index;
|
||
var sublen = matches[0].length;
|
||
sql = sql.substring(0, subpos) + repl + sql.substr(subpos + sublen);
|
||
|
||
//Zotero.debug("Hard-coding null bound parameter " + i);
|
||
|
||
params.splice(i, 1);
|
||
i--;
|
||
}
|
||
if (!params.length) {
|
||
params = [];
|
||
}
|
||
}
|
||
else if (/\?/g.test(sql)) {
|
||
throw new Error("Parameters not provided for query containing placeholders "
|
||
+ "[QUERY: " + sql + "]");
|
||
}
|
||
|
||
return [sql, params];
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.addCallback = function (type, cb) {
|
||
switch (type) {
|
||
case 'begin':
|
||
case 'commit':
|
||
case 'rollback':
|
||
break;
|
||
|
||
default:
|
||
throw ("Invalid callback type '" + type + "' in DB.addCallback()");
|
||
}
|
||
|
||
var id = this._callbacks[type].length;
|
||
this._callbacks[type][id] = cb;
|
||
return id;
|
||
}
|
||
|
||
|
||
/**
|
||
* Add a callback to run when the current transaction is committed or rolled back
|
||
*
|
||
* A rollback reverts the database but not JS state, and a transaction can contain many
|
||
* objects' saves (e.g., sync download batches), so one object's failure rolls back other
|
||
* objects' already-completed side effects. In-memory state (caches, properties of cached
|
||
* data objects) must therefore either be updated only in a 'commit' callback or restored
|
||
* to its previous state in a 'rollback' callback.
|
||
*
|
||
* @param {String} type - 'commit' or 'rollback'
|
||
* @param {Function} cb - Called with the transaction id after the transaction is
|
||
* committed or rolled back
|
||
*/
|
||
Zotero.DBConnection.prototype.addCurrentCallback = function (type, cb) {
|
||
this.requireTransaction();
|
||
this._callbacks.current[type].push(cb);
|
||
}
|
||
|
||
|
||
Zotero.DBConnection.prototype.removeCallback = function (type, id) {
|
||
switch (type) {
|
||
case 'begin':
|
||
case 'commit':
|
||
case 'rollback':
|
||
break;
|
||
|
||
default:
|
||
throw ("Invalid callback type '" + type + "' in DB.removeCallback()");
|
||
}
|
||
|
||
delete this._callbacks[type][id];
|
||
}
|
||
|
||
|
||
/*
|
||
* Used on shutdown to rollback all open transactions
|
||
*
|
||
* TODO: update or remove
|
||
*/
|
||
Zotero.DBConnection.prototype.rollbackAllTransactions = function () {
|
||
if (this.transactionInProgress()) {
|
||
var level = this._transactionNestingLevel;
|
||
this._transactionNestingLevel = 0;
|
||
try {
|
||
this.rollbackTransaction();
|
||
}
|
||
catch (e) {}
|
||
return level ? level : true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
|
||
Zotero.DBConnection.prototype.getColumns = function (table) {
|
||
return this.queryAsync("PRAGMA table_info(" + table + ")")
|
||
.then(function (rows) {
|
||
return rows.map(row => row.name);
|
||
})
|
||
.catch(function (e) {
|
||
this._debug(e, 1);
|
||
return false;
|
||
});
|
||
}
|
||
|
||
|
||
/**
|
||
* Find the next lowest numeric suffix for a value in table column
|
||
*
|
||
* For example, if "Untitled" and "Untitled 2" and "Untitled 4",
|
||
* returns "Untitled 3"
|
||
*
|
||
* If _name_ alone is available, returns that
|
||
**/
|
||
Zotero.DBConnection.prototype.getNextName = async function (libraryID, table, field, name) {
|
||
Zotero.debug("WARNING: Zotero.DB.getNextName() is deprecated -- "
|
||
+ "use Zotero.Utilities.Internal.getNextName() instead", 2);
|
||
|
||
if (typeof name == 'undefined') {
|
||
[libraryID, table, field, name] = [null, libraryID, table, field];
|
||
}
|
||
|
||
var sql = "SELECT SUBSTR(" + field + ", " + (name.length + 1) + ") FROM " + table
|
||
+ " WHERE libraryID=? AND " + field + " LIKE ? ORDER BY " + field;
|
||
var params = [libraryID, name + "%"];
|
||
var suffixes = await this.columnQueryAsync(sql, params);
|
||
suffixes.filter(x => x.match(/^( [0-9]+)?$/));
|
||
|
||
// If none found or first one has a suffix, use default name
|
||
if (!suffixes.length || suffixes[0]) {
|
||
return name;
|
||
}
|
||
|
||
suffixes.sort(function (a, b) {
|
||
return parseInt(a) - parseInt(b);
|
||
});
|
||
|
||
var i = 1;
|
||
while (suffixes[i] === "") {
|
||
i++;
|
||
}
|
||
var num = 2;
|
||
while (suffixes[i] == num) {
|
||
while (suffixes[i+1] && suffixes[i] == suffixes[i+1]) {
|
||
i++;
|
||
}
|
||
i++;
|
||
num++;
|
||
}
|
||
return name + ' ' + num;
|
||
};
|
||
|
||
|
||
//
|
||
// Async methods
|
||
//
|
||
//
|
||
// Zotero.DB.executeTransaction(async function (conn) {
|
||
// var created = await Zotero.DB.queryAsync("CREATE TEMPORARY TABLE tmpFoo (foo TEXT, bar INT)");
|
||
//
|
||
// // created == true
|
||
//
|
||
// var result = await Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('a', ?)", 1);
|
||
//
|
||
// // result == 1
|
||
//
|
||
// await Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('b', 2)");
|
||
// await Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('c', 3)");
|
||
// await Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('d', 4)");
|
||
//
|
||
// var value = await Zotero.DB.valueQueryAsync("SELECT foo FROM tmpFoo WHERE bar=?", 2);
|
||
//
|
||
// // value == "b"
|
||
//
|
||
// var vals = await Zotero.DB.columnQueryAsync("SELECT foo FROM tmpFoo");
|
||
//
|
||
// // '0' => "a"
|
||
// // '1' => "b"
|
||
// // '2' => "c"
|
||
// // '3' => "d"
|
||
//
|
||
// let rows = await Zotero.DB.queryAsync("SELECT * FROM tmpFoo");
|
||
// for (let i=0; i<rows.length; i++) {
|
||
// let row = rows[i];
|
||
// // row.foo == 'a', row.bar == 1
|
||
// // row.foo == 'b', row.bar == 2
|
||
// // row.foo == 'c', row.bar == 3
|
||
// // row.foo == 'd', row.bar == 4
|
||
// }
|
||
// });
|
||
//
|
||
/**
|
||
* In-memory state (caches, properties of cached data objects) modified within the
|
||
* transaction must be updated in a commit callback or restored in a rollback callback so
|
||
* that it doesn't reflect rolled-back changes -- see addCurrentCallback()
|
||
*
|
||
* @param {Function} func - Async function containing `await Zotero.DB.queryAsync()` and similar
|
||
* @param {Object} [options]
|
||
* @param {Boolean} [options.disableForeignKeys] - Disable foreign key checks before the
|
||
* transaction and re-enable after, while preventing any other queries from running.
|
||
* `queryAsync()` and similar within `func` must pass `ignoreDBLock: true` or they'll hang.
|
||
* (`PRAGMA foreign_keys=OFF|ON` is a no-op during a transaction, so it can't just be set within
|
||
* the function.)
|
||
* @return {Promise} - Promise for result of generator function
|
||
*/
|
||
Zotero.DBConnection.prototype.executeTransaction = async function (func, options = {}) {
|
||
var resolve;
|
||
|
||
var startedTransaction = false;
|
||
var committed = false;
|
||
var id = Zotero.Utilities.randomString();
|
||
|
||
try {
|
||
while (this._transactionID) {
|
||
await Promise.race([
|
||
this.waitForTransaction(id),
|
||
new Promise((resolve, reject) => setTimeout(
|
||
() => reject(new Zotero.DBConnection.TimeoutError()),
|
||
options.waitTimeout || 30000
|
||
))
|
||
]);
|
||
}
|
||
startedTransaction = true;
|
||
this._transactionID = id;
|
||
|
||
Zotero.debug(`Beginning DB transaction ${id}`, 4);
|
||
|
||
this._transactionPromise = new Zotero.Promise(function () {
|
||
resolve = arguments[0];
|
||
});
|
||
|
||
// Set a timestamp for this transaction
|
||
this._transactionDate = new Date(Math.floor(new Date / 1000) * 1000);
|
||
|
||
// Run begin callbacks
|
||
for (var i=0; i<this._callbacks.begin.length; i++) {
|
||
if (this._callbacks.begin[i]) {
|
||
this._callbacks.begin[i](id);
|
||
}
|
||
}
|
||
|
||
let result;
|
||
let resolveDBLockPromise;
|
||
try {
|
||
let conn = this._getConnection(options) || (await this._getConnectionAsync(options));
|
||
|
||
if (func.constructor.name == 'GeneratorFunction') {
|
||
throw new Error("Zotero.DB.executeTransaction() no longer takes a generator function "
|
||
+ "-- pass an async function instead");
|
||
}
|
||
|
||
if (options.disableForeignKeys) {
|
||
this._dbLockPromise = new Promise(function () {
|
||
resolveDBLockPromise = arguments[0];
|
||
});
|
||
await this.queryAsync("PRAGMA foreign_keys=OFF", [], { ignoreDBLock: true });
|
||
}
|
||
|
||
result = await conn.executeTransaction(func);
|
||
committed = true;
|
||
this._commitCount++;
|
||
Zotero.debug(`Committed DB transaction ${id}`, 4);
|
||
}
|
||
finally {
|
||
if (options.disableForeignKeys) {
|
||
await this.queryAsync("PRAGMA foreign_keys=ON", [], { ignoreDBLock: true });
|
||
if (resolveDBLockPromise) {
|
||
resolveDBLockPromise();
|
||
this._dbLockPromise = undefined;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clear transaction time
|
||
if (this._transactionDate) {
|
||
this._transactionDate = null;
|
||
}
|
||
|
||
this._transactionID = null;
|
||
|
||
// Function to run once transaction has been committed but before any
|
||
// permanent callbacks
|
||
if (options.onCommit) {
|
||
this._callbacks.current.commit.push(options.onCommit);
|
||
}
|
||
this._callbacks.current.rollback = [];
|
||
|
||
// Run temporary commit callbacks
|
||
//
|
||
// The transaction is already committed, so errors in commit callbacks are logged
|
||
// rather than being treated as transaction failures
|
||
var f;
|
||
while (f = this._callbacks.current.commit.shift()) {
|
||
try {
|
||
await Promise.resolve(f(id));
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
|
||
// Run commit callbacks
|
||
for (var i=0; i<this._callbacks.commit.length; i++) {
|
||
if (this._callbacks.commit[i]) {
|
||
try {
|
||
await this._callbacks.commit[i](id);
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
catch (e) {
|
||
if (e instanceof Zotero.DBConnection.TimeoutError) {
|
||
Zotero.debug(`Timed out waiting for transaction ${id}`, 1);
|
||
}
|
||
else if (committed) {
|
||
Zotero.debug(`Error after committing DB transaction ${id}`, 1);
|
||
Zotero.debug(e.message, 1);
|
||
}
|
||
else {
|
||
Zotero.debug(`Rolled back DB transaction ${id}`, 1);
|
||
Zotero.debug(e.message, 1);
|
||
}
|
||
if (startedTransaction) {
|
||
this._transactionID = null;
|
||
}
|
||
|
||
// If the transaction was committed before the error, don't run rollback
|
||
// callbacks, since the data was saved
|
||
if (committed) {
|
||
e.committed = true;
|
||
this._callbacks.current.commit = [];
|
||
this._callbacks.current.rollback = [];
|
||
throw e;
|
||
}
|
||
|
||
// Discard commit callbacks from the rolled-back transaction
|
||
this._callbacks.current.commit = [];
|
||
|
||
// Function to run once transaction has been rolled back but before any
|
||
// permanent callbacks
|
||
if (options.onRollback) {
|
||
this._callbacks.current.rollback.push(options.onRollback);
|
||
}
|
||
|
||
// Run temporary commit callbacks
|
||
var f;
|
||
while (f = this._callbacks.current.rollback.shift()) {
|
||
await Promise.resolve(f(id));
|
||
}
|
||
|
||
// Run rollback callbacks
|
||
for (var i=0; i<this._callbacks.rollback.length; i++) {
|
||
if (this._callbacks.rollback[i]) {
|
||
await Promise.resolve(this._callbacks.rollback[i](id));
|
||
}
|
||
}
|
||
|
||
throw e;
|
||
}
|
||
finally {
|
||
// Process all resolvers
|
||
if (resolve) {
|
||
resolve.call();
|
||
}
|
||
}
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.inTransaction = function () {
|
||
return !!this._transactionID;
|
||
}
|
||
|
||
|
||
Zotero.DBConnection.prototype.waitForTransaction = function (id) {
|
||
if (!this._transactionID) {
|
||
return Promise.resolve();
|
||
}
|
||
if (Zotero.Debug.enabled) {
|
||
Zotero.debug(`Waiting for DB transaction ${this._transactionID} to finish`
|
||
+ (id ? ` to start ${id}` : ""), 4);
|
||
Zotero.debug(Zotero.Debug.filterStack((new Error).stack), 5);
|
||
}
|
||
return this._transactionPromise;
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.requireTransaction = function () {
|
||
if (!this._transactionID) {
|
||
throw new Error("Not in transaction");
|
||
}
|
||
return this._transactionID;
|
||
};
|
||
|
||
|
||
/**
|
||
* @param {String} sql SQL statement to run
|
||
* @param {Array|String|Integer} [params] SQL parameters to bind
|
||
* @return {Promise|Array} A promise for an array of rows. The individual
|
||
* rows are Proxy objects that return values from the
|
||
* underlying mozIStorageRows based on column names.
|
||
*/
|
||
Zotero.DBConnection.prototype.queryAsync = async function (sql, params, options = {}) {
|
||
try {
|
||
let onRow = null;
|
||
let conn = this._getConnection(options) || (await this._getConnectionAsync(options));
|
||
if (!options || !options.noParseParams) {
|
||
[sql, params] = this.parseQueryAndParams(sql, params);
|
||
}
|
||
|
||
if (this._dbLockPromise && !options.ignoreDBLock) {
|
||
Zotero.debug(`Waiting for DB lock to be released: ${sql}`, 2);
|
||
await this._dbLockPromise;
|
||
}
|
||
|
||
if (Zotero.Debug.enabled) {
|
||
this.logQuery(sql, params, options);
|
||
}
|
||
var failed = false;
|
||
if (options && options.onRow) {
|
||
// Errors in onRow don't stop the query unless the 'cancel' function is called
|
||
onRow = function (row, cancel) {
|
||
try {
|
||
options.onRow(row, cancel);
|
||
}
|
||
catch (e) {
|
||
failed = e;
|
||
cancel();
|
||
}
|
||
}
|
||
}
|
||
let rows;
|
||
if (options && options.noCache) {
|
||
rows = await conn.execute(sql, params, onRow);
|
||
}
|
||
else {
|
||
rows = await conn.executeCached(sql, params, onRow);
|
||
}
|
||
if (failed) {
|
||
throw failed;
|
||
}
|
||
// Parse out the SQL command being used
|
||
let op = sql.match(/^[^a-z]*[^ ]+/i);
|
||
if (op) {
|
||
op = op.toString().toLowerCase();
|
||
}
|
||
|
||
// If SELECT statement, return result
|
||
if (op == 'select' || op == 'pragma') {
|
||
if (onRow) {
|
||
return;
|
||
}
|
||
// Fake an associative array with a proxy
|
||
let handler = {
|
||
get: function (target, name) {
|
||
// Ignore promise check
|
||
if (name == 'then') {
|
||
return undefined;
|
||
}
|
||
|
||
try {
|
||
return target.getResultByName(name);
|
||
}
|
||
catch (e) {
|
||
Zotero.debug(e, 1);
|
||
var msg = "DB column '" + name + "' not found";
|
||
Zotero.debug(msg, 1);
|
||
throw new Error(msg);
|
||
}
|
||
},
|
||
has: function (target, name) {
|
||
try {
|
||
return !!target.getResultByName(name);
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
};
|
||
for (let i=0, len=rows.length; i<len; i++) {
|
||
rows[i] = new Proxy(rows[i], handler);
|
||
}
|
||
return rows;
|
||
}
|
||
else {
|
||
// lastInsertRowID is unreliable for async queries, so we don't bother
|
||
// returning it for INSERT and REPLACE queries
|
||
return;
|
||
}
|
||
}
|
||
catch (e) {
|
||
await this._checkException(e);
|
||
|
||
if (e.errors && e.errors[0]) {
|
||
var eStr = e + "";
|
||
eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e;
|
||
throw new Error(eStr + ' [QUERY: ' + sql + '] '
|
||
+ (params
|
||
? '[PARAMS: '
|
||
+ (Array.isArray(params)
|
||
? params.map(x => JSON.stringify(x)).join(', ')
|
||
: JSON.stringify(params)
|
||
) + '] '
|
||
: '')
|
||
+ '[ERROR: ' + e.errors[0].message + ']');
|
||
}
|
||
else {
|
||
throw e;
|
||
}
|
||
}
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.queryTx = function (sql, params, options) {
|
||
return this.executeTransaction(async function () {
|
||
options = options || {};
|
||
delete options.tx;
|
||
return this.queryAsync(sql, params, options);
|
||
}.bind(this));
|
||
};
|
||
|
||
|
||
/**
|
||
* @param {String} sql SQL statement to run
|
||
* @param {Array|String|Integer} [params] SQL parameters to bind
|
||
* @return {Promise<Array|Boolean>} A promise for either the value or FALSE if no result
|
||
*/
|
||
Zotero.DBConnection.prototype.valueQueryAsync = async function (sql, params, options = {}) {
|
||
try {
|
||
let conn = this._getConnection(options) || (await this._getConnectionAsync(options));
|
||
[sql, params] = this.parseQueryAndParams(sql, params);
|
||
|
||
if (this._dbLockPromise && !options.ignoreDBLock) {
|
||
Zotero.debug(`Waiting for DB lock to be released: ${sql}`, 2);
|
||
await this._dbLockPromise;
|
||
}
|
||
|
||
if (Zotero.Debug.enabled) {
|
||
this.logQuery(sql, params, options);
|
||
}
|
||
let rows;
|
||
if (options && options.noCache) {
|
||
rows = await conn.execute(sql, params);
|
||
}
|
||
else {
|
||
rows = await conn.executeCached(sql, params);
|
||
}
|
||
return rows.length ? rows[0].getResultByIndex(0) : false;
|
||
}
|
||
catch (e) {
|
||
if (e.errors && e.errors[0]) {
|
||
var eStr = e + "";
|
||
eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e;
|
||
throw new Error(eStr + ' [QUERY: ' + sql + '] '
|
||
+ (params ? '[PARAMS: ' + params.join(', ') + '] ' : '')
|
||
+ '[ERROR: ' + e.errors[0].message + ']');
|
||
}
|
||
else {
|
||
throw e;
|
||
}
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* @param {String} sql SQL statement to run
|
||
* @param {Array|String|Integer} [params] SQL parameters to bind
|
||
* @return {Promise<Object>} A promise for a proxied storage row
|
||
*/
|
||
Zotero.DBConnection.prototype.rowQueryAsync = async function (sql, params) {
|
||
var rows = await this.queryAsync(sql, params);
|
||
return rows.length ? rows[0] : false;
|
||
};
|
||
|
||
|
||
/**
|
||
* @param {String} sql SQL statement to run
|
||
* @param {Array|String|Integer} [params] SQL parameters to bind
|
||
* @return {Promise<Array>} A promise for an array of values in the column
|
||
*/
|
||
Zotero.DBConnection.prototype.columnQueryAsync = async function (sql, params, options = {}) {
|
||
try {
|
||
let conn = this._getConnection(options) || (await this._getConnectionAsync(options));
|
||
[sql, params] = this.parseQueryAndParams(sql, params);
|
||
|
||
if (this._dbLockPromise && !options.ignoreDBLock) {
|
||
Zotero.debug(`Waiting for DB lock to be released: ${sql}`, 2);
|
||
await this._dbLockPromise;
|
||
}
|
||
|
||
if (Zotero.Debug.enabled) {
|
||
this.logQuery(sql, params, options);
|
||
}
|
||
let rows;
|
||
if (options && options.noCache) {
|
||
rows = await conn.execute(sql, params);
|
||
}
|
||
else {
|
||
rows = await conn.executeCached(sql, params);
|
||
}
|
||
var column = [];
|
||
for (let i=0, len=rows.length; i<len; i++) {
|
||
column.push(rows[i].getResultByIndex(0));
|
||
}
|
||
return column;
|
||
}
|
||
catch (e) {
|
||
if (e.errors && e.errors[0]) {
|
||
var eStr = e + "";
|
||
eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e;
|
||
throw new Error(eStr + ' [QUERY: ' + sql + '] '
|
||
+ (params ? '[PARAMS: ' + params.join(', ') + '] ' : '')
|
||
+ '[ERROR: ' + e.errors[0].message + ']');
|
||
}
|
||
else {
|
||
throw e;
|
||
}
|
||
}
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.logQuery = function (sql, params = [], options) {
|
||
if (options && options.debug === false) return;
|
||
var msg = sql;
|
||
if (params.length && (!options || options.debugParams !== false)) {
|
||
msg += " [";
|
||
for (let i = 0; i < params.length; i++) {
|
||
let param = params[i];
|
||
let paramType = typeof param;
|
||
if (paramType == 'string') {
|
||
msg += "'" + param + "', ";
|
||
}
|
||
else {
|
||
msg += param + ", ";
|
||
}
|
||
}
|
||
msg = msg.substr(0, msg.length - 2) + "]";
|
||
}
|
||
Zotero.debug(msg, 4);
|
||
}
|
||
|
||
|
||
Zotero.DBConnection.prototype.tableExists = async function (table, db) {
|
||
await this._getConnectionAsync();
|
||
var prefix = db ? db + '.' : '';
|
||
var sql = `SELECT COUNT(*) FROM ${prefix}sqlite_master WHERE type='table' AND tbl_name=?`;
|
||
var count = await this.valueQueryAsync(sql, [table]);
|
||
return !!count;
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.columnExists = async function (table, column) {
|
||
await this._getConnectionAsync();
|
||
var sql = `SELECT COUNT(*) FROM pragma_table_info(?) WHERE name=?`;
|
||
var count = await this.valueQueryAsync(sql, [table, column]);
|
||
return !!count;
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.indexExists = async function (index, db) {
|
||
await this._getConnectionAsync();
|
||
var prefix = db ? db + '.' : '';
|
||
var sql = `SELECT COUNT(*) FROM ${prefix}sqlite_master WHERE type='index' AND name=?`;
|
||
return !!(await this.valueQueryAsync(sql, [index]));
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.parseSQLFile = function (sql) {
|
||
var nonCommentRE = /^[^-]/;
|
||
var trailingCommentRE = /^(.*?)(?:--.+)?$/;
|
||
|
||
sql = sql.trim()
|
||
// Ugly hack to parse triggers with embedded semicolons
|
||
.replace(/;---/g, "TEMPSEMI")
|
||
.split("\n")
|
||
.filter(x => nonCommentRE.test(x))
|
||
.map(x => x.match(trailingCommentRE)[1])
|
||
.join("");
|
||
if (sql.substr(-1) == ";") {
|
||
sql = sql.substr(0, sql.length - 1);
|
||
}
|
||
|
||
var statements = sql.split(";")
|
||
.map(x => x.replace(/TEMPSEMI/g, ";"));
|
||
|
||
return statements;
|
||
};
|
||
|
||
|
||
/**
|
||
* Parse SQL string and execute transaction with all statements
|
||
*
|
||
* @return {Promise}
|
||
*/
|
||
Zotero.DBConnection.prototype.executeSQLFile = async function (sql) {
|
||
this.requireTransaction();
|
||
var statements = this.parseSQLFile(sql);
|
||
var statement;
|
||
while (statement = statements.shift()) {
|
||
await this.queryAsync(statement, false, { noCache: true });
|
||
}
|
||
};
|
||
|
||
|
||
/*
|
||
* Implements nsIObserver
|
||
*/
|
||
Zotero.DBConnection.prototype.observe = async function (subject, topic, data) {
|
||
switch (topic) {
|
||
case 'idle':
|
||
try {
|
||
await this.backUpDatabase({ online: true });
|
||
await this.vacuum();
|
||
// The main vacuum covers only the main database, so let callers reclaim space in
|
||
// their own attached databases (e.g., the full-text index) here too
|
||
for (let callback of this._idleCallbacks) {
|
||
try {
|
||
await callback();
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
// Truncate the WAL file so that a crash or force-quit leaves behind as
|
||
// little stale WAL data as possible. A leftover -wal file is replayed into
|
||
// whatever file next occupies the database path (e.g., a backup manually
|
||
// copied into place), corrupting it, and an empty one is harmless.
|
||
if (this._connection) {
|
||
await this.queryAsync("PRAGMA wal_checkpoint(TRUNCATE)");
|
||
}
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
|
||
Zotero.DBConnection.prototype.numCachedStatements = function () {
|
||
return this._connection._connectionData._cachedStatements.size;
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.getCachedStatements = function () {
|
||
return [...this._connection._connectionData._cachedStatements].map(x => x[0]);
|
||
};
|
||
|
||
|
||
/**
|
||
* Vacuum the database using VACUUM INTO and perform an atomic file swap
|
||
*
|
||
* Creates a compacted copy of the database without blocking writes during
|
||
* the copy phase, then closes the connection and atomically replaces the
|
||
* original file if no writes occurred during compaction.
|
||
*
|
||
* @param {Object} [options]
|
||
* @param {Boolean} [options.force] - Skip time/freelist/disk-space checks
|
||
* @return {Promise<Boolean>} - Whether vacuum was performed
|
||
*/
|
||
Zotero.DBConnection.prototype.vacuum = async function ({ force } = {}) {
|
||
if (this._externalDB) {
|
||
return false;
|
||
}
|
||
|
||
if (this.inTransaction()) {
|
||
await this.waitForTransaction();
|
||
}
|
||
|
||
if (!force) {
|
||
// Check time threshold
|
||
let lastVacuum = Zotero.Prefs.get('vacuum.lastTime') || 0;
|
||
let intervalDays = Zotero.Prefs.get('vacuum.interval') || 14;
|
||
let intervalSeconds = intervalDays * 24 * 60 * 60;
|
||
let nowSeconds = Math.floor(Date.now() / 1000);
|
||
if ((nowSeconds - lastVacuum) < intervalSeconds) {
|
||
Zotero.debug("Database was vacuumed recently -- skipping");
|
||
return false;
|
||
}
|
||
|
||
// Check freelist threshold
|
||
let freelistCount = await this.valueQueryAsync("PRAGMA freelist_count");
|
||
let pageCount = await this.valueQueryAsync("PRAGMA page_count");
|
||
let threshold = Zotero.Prefs.get('vacuum.freelistThreshold') || 10;
|
||
if (pageCount > 0 && (freelistCount / pageCount * 100) < threshold) {
|
||
Zotero.debug(`Database freelist is ${freelistCount}/${pageCount} pages `
|
||
+ `(${(freelistCount / pageCount * 100).toFixed(1)}%) `
|
||
+ `-- below ${threshold}% threshold, skipping`);
|
||
return false;
|
||
}
|
||
|
||
// Check disk space
|
||
let dbFile = Zotero.File.pathToFile(this._dbPath);
|
||
let dbSize = (await IOUtils.stat(this._dbPath)).size;
|
||
let freeSpace = dbFile.diskSpaceAvailable;
|
||
if (freeSpace < dbSize) {
|
||
Zotero.debug(`Not enough disk space to vacuum database `
|
||
+ `(${freeSpace} available, ${dbSize} needed) -- skipping`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
let tmpFile = this._dbPath + '.vacuum.tmp';
|
||
|
||
try {
|
||
// Clean up any leftover temp file from a previous failed attempt
|
||
if (await IOUtils.exists(tmpFile)) {
|
||
await IOUtils.remove(tmpFile);
|
||
}
|
||
|
||
Zotero.debug("Vacuuming database");
|
||
let t = new Date();
|
||
|
||
let commitCountBefore = this._commitCount;
|
||
|
||
// Disable auto_vacuum for the output file if previously enabled -- periodic VACUUM handles
|
||
// compaction, and auto_vacuum causes fragmentation
|
||
await this.queryAsync("PRAGMA auto_vacuum=0");
|
||
|
||
// VACUUM INTO creates a compacted copy as a snapshot of committed data. Concurrent writes
|
||
// in WAL are NOT included in the output.
|
||
await this.queryAsync(`VACUUM INTO '${tmpFile.replace(/'/g, "''")}'`);
|
||
|
||
// Block other code from reopening the connection during the swap
|
||
let resolveVacuumPromise;
|
||
this._offlineBackupPromise = new Promise(function () {
|
||
resolveVacuumPromise = arguments[0];
|
||
});
|
||
|
||
try {
|
||
// Close the database -- this waits for any in-flight transactions and checkpoints WAL
|
||
await this.closeDatabase();
|
||
|
||
// If any writes happened between VACUUM INTO start and close, the compacted copy is
|
||
// stale -- abort
|
||
if (this._commitCount !== commitCountBefore) {
|
||
Zotero.debug("Database was modified during vacuum -- aborting swap", 1);
|
||
await IOUtils.remove(tmpFile);
|
||
return false;
|
||
}
|
||
|
||
// Atomic swap
|
||
await IOUtils.move(tmpFile, this._dbPath);
|
||
|
||
Zotero.Prefs.set('vacuum.lastTime', Math.floor(Date.now() / 1000));
|
||
Zotero.debug("Vacuumed database in " + (new Date() - t) + " ms");
|
||
|
||
return true;
|
||
}
|
||
finally {
|
||
this._offlineBackupPromise = null;
|
||
resolveVacuumPromise();
|
||
}
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
try {
|
||
if (await IOUtils.exists(tmpFile)) {
|
||
await IOUtils.remove(tmpFile);
|
||
}
|
||
}
|
||
catch (e2) {
|
||
Zotero.logError(e2);
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
|
||
// TEMP
|
||
Zotero.DBConnection.prototype.info = async function () {
|
||
var info = {};
|
||
var pragmas = ['auto_vacuum', 'cache_size', 'journal_mode', 'main.locking_mode', 'page_size', 'synchronous'];
|
||
for (let p of pragmas) {
|
||
info[p] = await Zotero.DB.valueQueryAsync(`PRAGMA ${p}`);
|
||
}
|
||
return info;
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.quickCheck = async function () {
|
||
var ok = await this.valueQueryAsync("PRAGMA quick_check(1)");
|
||
return ok == 'ok';
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.integrityCheck = async function () {
|
||
var ok = await this.valueQueryAsync("PRAGMA integrity_check(1)");
|
||
return ok == 'ok';
|
||
};
|
||
|
||
|
||
/**
|
||
* Load a bundled SQLite extension (e.g., 'fts5') on the connection.
|
||
*
|
||
* Mozilla's mozStorage doesn't compile FTS in by default and disables generic extension loading,
|
||
* but it does allow loading specific bundled extensions by name. The module is registered per
|
||
* connection, so call this once: the extension is remembered and re-loaded automatically when the
|
||
* connection is reopened (e.g., after a vacuum()), before onConnect() callbacks run, so callers
|
||
* don't have to reload it themselves.
|
||
*/
|
||
Zotero.DBConnection.prototype.loadExtension = async function (name) {
|
||
var conn = await this._getConnectionAsync();
|
||
var result = conn._connectionData._dbConn.loadExtension(name);
|
||
// Remember it so it can be re-loaded on the next reconnect
|
||
this._loadedExtensions.add(name);
|
||
return result;
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype.isCorruptionError = function (e) {
|
||
return this.DB_CORRUPTION_STRINGS.some(x => e.message?.includes(x))
|
||
// Opening a corrupted or non-database file can throw with this nsresult and no
|
||
// matching message text
|
||
|| e.result == Cr.NS_ERROR_FILE_CORRUPTED;
|
||
};
|
||
|
||
|
||
/**
|
||
* Register a callback to run when a corruption error occurs but the main database checks out,
|
||
* meaning an attached database (e.g., the rebuildable full-text index) is corrupt. The callback
|
||
* can rebuild it. Run deferred, after the failing operation unwinds.
|
||
*/
|
||
Zotero.DBConnection.prototype.addCorruptionHandler = function (handler) {
|
||
this._corruptionHandlers.push(handler);
|
||
};
|
||
|
||
|
||
/**
|
||
* Register a callback to run during the database's idle maintenance, after the periodic backup and
|
||
* vacuum. Lets code with its own attached database do maintenance (e.g., vacuuming) on the same
|
||
* idle trigger.
|
||
*/
|
||
Zotero.DBConnection.prototype.onIdle = function (callback) {
|
||
this._idleCallbacks.push(callback);
|
||
};
|
||
|
||
|
||
/**
|
||
* Close the database
|
||
* @param {Boolean} [permanent] If true, throw an error instead of
|
||
* allowing code to re-open the database again
|
||
*/
|
||
Zotero.DBConnection.prototype.closeDatabase = async function (permanent) {
|
||
if (this._connection) {
|
||
// TODO: Replace with automatic detection of likely improperly cached statements
|
||
// (multiple similar statements, "tmp_", embedded ids)
|
||
if (Zotero.isSourceBuild) {
|
||
try {
|
||
Zotero.debug("Cached DB statements: " + this.numCachedStatements());
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e, 1);
|
||
}
|
||
}
|
||
|
||
Zotero.debug("Closing database");
|
||
|
||
// Checkpoint WAL before closing so all data is in the main file
|
||
// and -wal file is truncated. Use _connection.execute() directly
|
||
// to avoid deadlocking with _offlineBackupPromise in queryAsync.
|
||
//
|
||
// Skip this if the database is flagged as corrupt, since checkpointing would write
|
||
// potentially bad WAL data into the database file, which might be a valid file that a
|
||
// stale WAL file is being incorrectly replayed into.
|
||
if (!this._dbIsCorrupt) {
|
||
try {
|
||
Zotero.debug("PRAGMA wal_checkpoint(TRUNCATE)");
|
||
await this._connection.execute("PRAGMA wal_checkpoint(TRUNCATE)");
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
|
||
this.closed = true;
|
||
await this._connection.close();
|
||
this._connection = undefined;
|
||
this._connection = permanent ? false : null;
|
||
Zotero.debug("Database closed");
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* Register a callback to run after the SQLite connection is opened, including after it has
|
||
* been closed and reopened (e.g., after vacuum()). Use this for per-connection state that
|
||
* doesn't persist across reopens, such as ATTACHed databases or temp tables.
|
||
*
|
||
* The callback is not invoked for the connection that's already open at registration time --
|
||
* the caller is responsible for any initial setup.
|
||
*
|
||
* @param {Function} callback - Async function called with no arguments after each (re)open
|
||
*/
|
||
Zotero.DBConnection.prototype.onConnect = function (callback) {
|
||
this._onConnectCallbacks.push(callback);
|
||
};
|
||
|
||
|
||
/**
|
||
* @deprecated
|
||
*/
|
||
Zotero.DBConnection.prototype.backupDatabase = async function (_suffix, _force) {
|
||
Zotero.debug("backupDatabase(suffix, force) is now backUpDatabase({ suffix, force }) -- update your code");
|
||
return this.backUpDatabase({ suffix: arguments[0], force: arguments[1] });
|
||
};
|
||
|
||
/**
|
||
* @param {Object} [options]
|
||
* @param {Boolean} [options.force] - Perform backup even if not enough time has passed since last one
|
||
* @param {String} [options.suffix] - Suffix to add to 'zotero.sqlite.' before 'bak' (e.g., '123'
|
||
* for zotero.sqlite.123.bak)
|
||
* @param {Boolean} [options.online] - Perform an online incremental backup without closing connection
|
||
*/
|
||
Zotero.DBConnection.prototype.backUpDatabase = async function ({ force, suffix, online } = {}) {
|
||
if (this.skipBackup || this._externalDB || Zotero.skipLoading) {
|
||
this._debug("Skipping backup of database '" + this._dbName + "'", 1);
|
||
return false;
|
||
}
|
||
|
||
var storageService = Services.storage;
|
||
|
||
var numBackups = Zotero.Prefs.get("backup.numBackups");
|
||
if (!suffix) {
|
||
// Skip regular backups if numBackups is 0
|
||
if (numBackups < 1) {
|
||
return false;
|
||
}
|
||
if (numBackups > 24) {
|
||
numBackups = 24;
|
||
}
|
||
}
|
||
|
||
if (Zotero.locked && !force) {
|
||
this._debug("Zotero is locked -- skipping backup of DB '" + this._dbName + "'", 2);
|
||
return false;
|
||
}
|
||
|
||
if (this.inTransaction()) {
|
||
await this.waitForTransaction();
|
||
}
|
||
|
||
// Skip online backup if a backup is already in progress
|
||
if (online && (this._offlineBackupPromise || this._onlineBackupInProgress)) {
|
||
this._debug("Database " + this._dbName + " is already being backed up -- skipping online backup", 2);
|
||
return false;
|
||
}
|
||
|
||
// Skip offline backup if one is already in progress, but wait to return until we actually have
|
||
// an up-to-date backup
|
||
if (this._offlineBackupPromise) {
|
||
this._debug("Database " + this._dbName + " is already being backed up -- waiting for backup to finish", 2);
|
||
return this._offlineBackupPromise;
|
||
}
|
||
|
||
// On APFS, use cloning for all backups -- it's nearly instant and backup files share
|
||
// disk blocks via copy-on-write, saving potentially gigabytes of space
|
||
if (online && Zotero.File.isAPFS(this._dbPath)) {
|
||
online = false;
|
||
}
|
||
|
||
// On Linux, use an offline backup on a network filesystem. The online backup API writes
|
||
// the backup file through SQLite's default VFS, whose locking can hang there -- its lock
|
||
// upgrades conflict with the SMB byte-range lock mapping on CIFS mounts. Check the
|
||
// directory rather than the database file, which could be a symlink to another volume,
|
||
// since the directory is where the backup files are written.
|
||
if (online && Zotero.isLinux
|
||
&& ['cifs', 'smb', 'smb2', 'nfs'].includes(
|
||
Zotero.File.getFileSystemInfo(PathUtils.parent(this._dbPath))?.fsType
|
||
)) {
|
||
online = false;
|
||
}
|
||
|
||
var resolveOfflineBackupPromise;
|
||
var success = false;
|
||
if (online) {
|
||
this._onlineBackupInProgress = true;
|
||
}
|
||
// For offline backups, start a promise that will be resolved when the backup finishes
|
||
else {
|
||
this._offlineBackupPromise = new Promise(function () {
|
||
resolveOfflineBackupPromise = arguments[0];
|
||
});
|
||
}
|
||
|
||
try {
|
||
let corruptMarker = Zotero.File.pathToFile(this._dbPath + '.is.corrupt');
|
||
if (this._dbIsCorrupt || corruptMarker.exists()) {
|
||
this._debug("Database '" + this._dbName + "' is marked as corrupt -- skipping backup", 1);
|
||
return false;
|
||
}
|
||
|
||
let file = this._dbPath;
|
||
|
||
// For standard backup, make sure last backup is old enough to replace
|
||
if (!suffix && !force) {
|
||
let backupFile = this._dbPath + '.bak';
|
||
if (await OS.File.exists(backupFile)) {
|
||
let currentDBTime = (await OS.File.stat(file)).lastModificationDate;
|
||
let lastBackupTime = (await OS.File.stat(backupFile)).lastModificationDate;
|
||
// In WAL mode the database file's mtime advances only when the WAL is
|
||
// checkpointed, so changes still in the WAL leave it matching the backup
|
||
if (currentDBTime.getTime() == lastBackupTime.getTime()
|
||
&& !(await this._hasWALContents(file))) {
|
||
Zotero.debug("Database '" + this._dbName + "' hasn't changed -- skipping backup");
|
||
return false;
|
||
}
|
||
|
||
var now = new Date();
|
||
var intervalMinutes = Zotero.Prefs.get('backup.interval');
|
||
var interval = intervalMinutes * 60 * 1000;
|
||
if ((now - lastBackupTime) < interval) {
|
||
Zotero.debug("Last backup of database '" + this._dbName
|
||
+ "' was less than " + intervalMinutes + " minutes ago -- skipping backup");
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
this._debug("Backing up database '" + this._dbName + "'");
|
||
|
||
// Copy via a temporary file so we don't run into disk space issues
|
||
// after deleting the old backup file
|
||
var tmpFile = this._dbPath + '.tmp';
|
||
if (await OS.File.exists(tmpFile)) {
|
||
try {
|
||
await OS.File.remove(tmpFile);
|
||
}
|
||
catch (e) {
|
||
if (e.name == 'NS_ERROR_FILE_ACCESS_DENIED') {
|
||
alert("Cannot delete " + PathUtils.filename(tmpFile));
|
||
}
|
||
throw (e);
|
||
}
|
||
}
|
||
|
||
if (online) {
|
||
// Default page size is 4096 bytes, so a 100 MiB database will copy in about 25 seconds
|
||
// (100 × 1024 × 1024 / 4096 / 256 × 250 / 1000) plus actual copying time, while a 1 GiB
|
||
// database will copy in 4.25 minutes (1024 × 1024 × 1024 / 4096 / 256 × 250 / 1000 / 60)
|
||
// plus copying time
|
||
const PAGES_PER_STEP = 256;
|
||
await this._connection.backup(tmpFile, PAGES_PER_STEP);
|
||
}
|
||
else {
|
||
try {
|
||
await this.closeDatabase();
|
||
await Zotero.File.copyFile(this._dbPath, tmpFile);
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Open the backup to check for corruption
|
||
try {
|
||
var connection = storageService.openDatabase(Zotero.File.pathToFile(tmpFile));
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
this._debug("Database file '" + PathUtils.filename(tmpFile) + "' can't be opened -- skipping backup");
|
||
if (await OS.File.exists(tmpFile)) {
|
||
await OS.File.remove(tmpFile);
|
||
}
|
||
return false;
|
||
}
|
||
finally {
|
||
if (connection) {
|
||
let deferred = Zotero.Promise.defer();
|
||
connection.asyncClose({
|
||
complete: function () {
|
||
deferred.resolve();
|
||
}
|
||
});
|
||
await deferred.promise;
|
||
}
|
||
}
|
||
|
||
// Special backup
|
||
if (!suffix && numBackups > 1) {
|
||
// Remove oldest backup file
|
||
let targetFile = this._dbPath + '.' + (numBackups - 1) + '.bak';
|
||
if (await OS.File.exists(targetFile)) {
|
||
await OS.File.remove(targetFile);
|
||
}
|
||
|
||
// Shift old versions up
|
||
for (var i=(numBackups - 1); i>=1; i--) {
|
||
var targetNum = i;
|
||
var sourceNum = targetNum - 1;
|
||
|
||
let targetFile = this._dbPath + '.' + targetNum + '.bak';
|
||
let sourceFile = this._dbPath + '.' + (sourceNum ? sourceNum + '.bak' : 'bak');
|
||
|
||
if (!(await OS.File.exists(sourceFile))) {
|
||
continue;
|
||
}
|
||
|
||
Zotero.debug("Moving " + PathUtils.filename(sourceFile)
|
||
+ " to " + PathUtils.filename(targetFile));
|
||
await OS.File.move(sourceFile, targetFile);
|
||
}
|
||
}
|
||
|
||
let backupFile = this._dbPath + '.' + (suffix ? suffix + '.' : '') + 'bak';
|
||
|
||
// Remove old backup file
|
||
if (await OS.File.exists(backupFile)) {
|
||
OS.File.remove(backupFile);
|
||
}
|
||
|
||
await OS.File.move(tmpFile, backupFile);
|
||
|
||
// A copy preserves the database file's mtime, and the backup interval is measured from
|
||
// the backup file's mtime, so a forced backup of a long-idle database could be rotated
|
||
// out a day early. Date forced backups from the backup itself so that they last a full
|
||
// rotation period.
|
||
if (force && !suffix) {
|
||
await IOUtils.setModificationTime(backupFile);
|
||
}
|
||
|
||
Zotero.debug("Backed up to " + PathUtils.filename(backupFile));
|
||
success = true;
|
||
return true;
|
||
}
|
||
catch (e) {
|
||
// Backups are best-effort, so if anything goes wrong dealing
|
||
// with them -- e.g., an offline backup file on a network drive
|
||
// that can't be accessed, which fails with ERROR_FILE_OFFLINE
|
||
// (https://forums.zotero.org/discussion/132201/) -- log the
|
||
// error and skip the backup rather than letting it block a
|
||
// schema upgrade or startup.
|
||
Zotero.logError(e);
|
||
return false;
|
||
}
|
||
finally {
|
||
if (online) {
|
||
this._onlineBackupInProgress = false;
|
||
}
|
||
else {
|
||
resolveOfflineBackupPromise(success);
|
||
this._offlineBackupPromise = undefined;
|
||
}
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* Escape '_', '%', and '\' in an SQL LIKE expression so that it can be used with ESCAPE '\' to
|
||
* prevent the wildcards from having special meaning
|
||
*/
|
||
Zotero.DBConnection.prototype.escapeSQLExpression = function (expr) {
|
||
return expr.replace(/([_%\\])/g, '\\$1');
|
||
};
|
||
|
||
|
||
/////////////////////////////////////////////////////////////////
|
||
//
|
||
// Private methods
|
||
//
|
||
/////////////////////////////////////////////////////////////////
|
||
|
||
Zotero.DBConnection.prototype._getConnection = function () {
|
||
if (this._offlineBackupPromise) {
|
||
return false;
|
||
}
|
||
if (this._connection === false) {
|
||
throw new Error("Database permanently closed; not re-opening");
|
||
}
|
||
return this._connection || false;
|
||
}
|
||
|
||
/*
|
||
* Retrieve a link to the data store asynchronously
|
||
*/
|
||
Zotero.DBConnection.prototype._getConnectionAsync = async function () {
|
||
// If a backup is in progress, wait until it's done
|
||
if (this._offlineBackupPromise) {
|
||
Zotero.debug("Waiting for database backup to complete", 2);
|
||
await this._offlineBackupPromise;
|
||
}
|
||
|
||
if (this._connection) {
|
||
return this._connection;
|
||
}
|
||
else if (this._connection === false) {
|
||
throw new Error("Database permanently closed; not re-opening");
|
||
}
|
||
|
||
this._debug("Asynchronously opening database '" + this._dbName + "'");
|
||
Zotero.debug(this._dbPath);
|
||
|
||
// Get the storage service
|
||
var store = Services.storage;
|
||
|
||
var file = this._dbPath;
|
||
var corruptMarker = this._dbPath + '.is.corrupt';
|
||
|
||
var uncleanShutdown = false;
|
||
var useWAL = true;
|
||
if (!this._externalDB) {
|
||
// If a verified copy of the database file was saved before a restart due to stale
|
||
// journal files, swap it in. A failure aborts startup, since opening the database
|
||
// with a pending repair still on disk could let the older copy overwrite newer data
|
||
// at a later startup.
|
||
await this._applyPendingRepair();
|
||
|
||
// A non-empty WAL file means the last session didn't close cleanly, since the WAL
|
||
// is truncated at shutdown
|
||
try {
|
||
uncleanShutdown = (await IOUtils.stat(file + '-wal')).size > 0;
|
||
}
|
||
catch (e) {
|
||
if (e.name != 'NotFoundError') {
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
useWAL = await this._canUseWAL(file);
|
||
}
|
||
|
||
try {
|
||
if (await OS.File.exists(corruptMarker)) {
|
||
throw new Error(this.DB_CORRUPTION_STRINGS[0]);
|
||
}
|
||
// A database in WAL mode on a filesystem that can't use WAL has to be converted
|
||
// before it's opened, since just opening it crashes -- see _canUseWAL(). A corruption
|
||
// error from the conversion goes through the same recovery as one from the open.
|
||
if (!this._externalDB && !useWAL) {
|
||
await this._downgradeDatabaseFromWAL(file);
|
||
}
|
||
// On macOS, open without an exclusive lock at the OS level so the database can be opened
|
||
// on network filesystems (e.g., SMB shares), where acquiring the exclusive open lock
|
||
// fails with an I/O error. We still set locking_mode=EXCLUSIVE below, which gives us a
|
||
// connection-lifetime SQLite lock preventing undetected concurrent writes. See #4860.
|
||
//
|
||
// On other platforms, keep the default exclusive open, which performs all locking under
|
||
// a single held lock -- avoiding SQLite's lock-upgrade sequence, which fails on some
|
||
// network filesystems (e.g., Linux CIFS mounts) -- and keeps the WAL index in heap
|
||
// memory, so no -shm file is created.
|
||
this._connection = await Promise.resolve(this.Sqlite.openConnection({
|
||
path: file,
|
||
openNotExclusive: Zotero.isMac
|
||
}));
|
||
}
|
||
catch (e) {
|
||
// Don't deal with corrupted external dbs
|
||
if (this._externalDB) {
|
||
throw e;
|
||
}
|
||
|
||
Zotero.logError(e);
|
||
|
||
if (this.isCorruptionError(e)) {
|
||
await this._handleCorruptionMarker();
|
||
// Recovery just verified or replaced the database file and removed any journal
|
||
// files, so the unclean-shutdown integrity check below would be redundant
|
||
uncleanShutdown = false;
|
||
}
|
||
else {
|
||
// Some other error that we don't yet know how to deal with
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
if (!this._externalDB) {
|
||
if (DB_LOCK_EXCLUSIVE) {
|
||
await this.queryAsync("PRAGMA main.locking_mode=EXCLUSIVE");
|
||
}
|
||
else {
|
||
await this.queryAsync("PRAGMA main.locking_mode=NORMAL");
|
||
}
|
||
|
||
if (useWAL) {
|
||
// Enable WAL mode for better write performance
|
||
await this.queryAsync("PRAGMA journal_mode=WAL");
|
||
// NORMAL synchronous is safe with WAL -- only risks losing the last
|
||
// transaction on power loss, not corruption
|
||
await this.queryAsync("PRAGMA synchronous=NORMAL");
|
||
}
|
||
|
||
// Set page cache size to 8MB
|
||
let pageSize = await this.valueQueryAsync("PRAGMA page_size");
|
||
let cacheSize = 8192000 / pageSize;
|
||
await this.queryAsync("PRAGMA cache_size=" + cacheSize);
|
||
|
||
// Enable foreign key checks
|
||
await this.queryAsync("PRAGMA foreign_keys=true");
|
||
|
||
// If the last session didn't close cleanly, check database integrity, in case the WAL
|
||
// doesn't belong to the database file (e.g., a stale WAL from a force-quit left in
|
||
// place while the database file was manually replaced from a backup). A mismatched
|
||
// WAL can produce subtle data damage rather than outright errors, so use a full
|
||
// integrity check, which unlike quick_check verifies indexes against table contents.
|
||
if (uncleanShutdown) {
|
||
this._debug("Last session didn't close cleanly -- checking database integrity", 1);
|
||
this._showProgressText('db-checking-integrity');
|
||
let ok = false;
|
||
try {
|
||
ok = (await this.valueQueryAsync("PRAGMA integrity_check(1)")) == 'ok';
|
||
}
|
||
catch (e) {
|
||
// Only a corruption error confirms corruption -- operational errors propagate
|
||
if (!this.isCorruptionError(e)) {
|
||
throw e;
|
||
}
|
||
Zotero.logError(e);
|
||
}
|
||
if (ok) {
|
||
this._debug("Database integrity OK", 1);
|
||
}
|
||
else {
|
||
let error = new Error(this.DB_CORRUPTION_STRINGS[0]);
|
||
// The full check verifies index contents that quick_check skips, so don't let
|
||
// the attached-database quick check overrule it
|
||
await this._checkException(error, { mainConfirmedCorrupt: true });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Register idle observer for DB backup
|
||
Zotero.Schema.schemaUpdatePromise.then(() => {
|
||
Zotero.debug("Initializing DB backup idle observer");
|
||
var idleService = Components.classes["@mozilla.org/widget/useridleservice;1"]
|
||
.getService(Components.interfaces.nsIUserIdleService);
|
||
idleService.addIdleObserver(this, 300);
|
||
});
|
||
}
|
||
|
||
// Re-load any extensions loaded via loadExtension(), which are registered per connection and
|
||
// so don't survive a reconnect. Done before the onConnect callbacks, which may depend on them
|
||
// (e.g., creating FTS5 tables).
|
||
for (let name of this._loadedExtensions) {
|
||
try {
|
||
this._connection._connectionData._dbConn.loadExtension(name);
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
|
||
for (let callback of this._onConnectCallbacks) {
|
||
try {
|
||
await callback();
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
|
||
return this._connection;
|
||
};
|
||
|
||
|
||
/**
|
||
* Check whether the database's WAL file contains data not yet in the database file
|
||
*
|
||
* @param {String} file - Path to the database file
|
||
* @return {Promise<Boolean>}
|
||
*/
|
||
Zotero.DBConnection.prototype._hasWALContents = async function (file) {
|
||
try {
|
||
return (await IOUtils.stat(file + '-wal')).size > 0;
|
||
}
|
||
catch (e) {
|
||
if (e.name != 'NotFoundError') {
|
||
throw e;
|
||
}
|
||
return false;
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* Determine whether the database file can use WAL journal mode
|
||
*
|
||
* WAL requires SQLite's shared-memory support. On macOS, SQLite chooses its locking methods
|
||
* based on the filesystem containing the database, and network filesystems (e.g., SMB, NFS,
|
||
* WebDAV), read-only volumes, and filesystems without byte-range locking get methods without
|
||
* shared-memory support. Opening a WAL database with those crashes, because Mozilla's VFS
|
||
* wrapper hides the missing shared-memory methods from SQLite's WAL support check, so mirror
|
||
* SQLite's selection logic and allow WAL only when it would select methods with shared-memory
|
||
* support.
|
||
*
|
||
* @param {String} file - Path to the database file
|
||
* @return {Promise<Boolean>}
|
||
*/
|
||
Zotero.DBConnection.prototype._canUseWAL = async function (file) {
|
||
if (!Zotero.isMac) {
|
||
return true;
|
||
}
|
||
let info = Zotero.File.getFileSystemInfo(file);
|
||
if (!info) {
|
||
return false;
|
||
}
|
||
Zotero.debug(`Database is on ${info.fsType} filesystem`);
|
||
// Filesystems that SQLite maps by name to locking methods without shared-memory support,
|
||
// plus read-only volumes, which get no-op locking
|
||
if (['afpfs', 'smbfs', 'webdav', 'nfs'].includes(info.fsType) || info.readOnly) {
|
||
return false;
|
||
}
|
||
// For other filesystems, SQLite probes byte-range locking support and falls back to
|
||
// dot-file locking without shared-memory support if it's missing
|
||
return Zotero.File.supportsByteRangeLocks(file);
|
||
};
|
||
|
||
|
||
/**
|
||
* Convert a database in WAL mode back to a rollback journal
|
||
*
|
||
* If the WAL file is missing or empty, revert the format versions in the database header
|
||
* directly. Otherwise replay the WAL by converting a temporary copy of the database on
|
||
* local disk and swapping it in after it passes an integrity check. If the converted copy
|
||
* fails the check -- e.g., because the WAL is stale and doesn't belong to the database
|
||
* file -- but the database file is valid on its own, discard the WAL instead. The original
|
||
* files aren't modified until a validated replacement is in place.
|
||
*
|
||
* A WAL file next to a database whose header already has the rollback format versions --
|
||
* e.g., from a conversion interrupted between the swap and the WAL removal, or a database
|
||
* file manually restored from a backup with a stale WAL left in place -- goes through the
|
||
* same conversion, since SQLite applies a WAL file based on its presence alone.
|
||
*
|
||
* @param {String} file - Path to the database file
|
||
* @return {Promise<Boolean>} - True if the database was converted
|
||
*/
|
||
Zotero.DBConnection.prototype._downgradeDatabaseFromWAL = async function (file) {
|
||
// SQLite canonicalizes the database path, so the journal files of a symlinked database
|
||
// sit next to the symlink's target, and the target is what has to be converted
|
||
try {
|
||
let nsFile = Zotero.File.pathToFile(file);
|
||
nsFile.normalize();
|
||
file = nsFile.path;
|
||
}
|
||
catch (e) {
|
||
// Leave the path as is if it can't be resolved (e.g., the file doesn't exist)
|
||
}
|
||
|
||
let header;
|
||
try {
|
||
header = await IOUtils.read(file, { maxBytes: 20 });
|
||
}
|
||
catch (e) {
|
||
if (e.name == 'NotFoundError') {
|
||
return false;
|
||
}
|
||
throw e;
|
||
}
|
||
// Bytes 18 and 19 are the write and read format versions -- 2 means WAL
|
||
let isWALHeader = header.length >= 20 && header[18] == 2;
|
||
|
||
let walSize = null;
|
||
try {
|
||
walSize = (await IOUtils.stat(file + '-wal')).size;
|
||
}
|
||
catch (e) {
|
||
if (e.name != 'NotFoundError') {
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
if (!isWALHeader && walSize === null) {
|
||
return false;
|
||
}
|
||
|
||
Zotero.debug(isWALHeader
|
||
? "Database is in WAL mode -- converting to rollback journal"
|
||
: "Database has a leftover WAL file -- applying and removing it");
|
||
|
||
if (walSize > 0) {
|
||
let tempFile = PathUtils.join(
|
||
PathUtils.tempDir, `zotero.${Zotero.Utilities.randomString()}.sqlite`
|
||
);
|
||
let swapFile = file + '.convert-tmp';
|
||
try {
|
||
await IOUtils.copy(file, tempFile);
|
||
await IOUtils.copy(file + '-wal', tempFile + '-wal');
|
||
// Only a corruption error marks the copy as invalid -- operational errors
|
||
// (I/O, permissions) propagate
|
||
let valid = false;
|
||
try {
|
||
let conn = await this.Sqlite.openConnection({ path: tempFile });
|
||
try {
|
||
await conn.execute("PRAGMA journal_mode=DELETE");
|
||
}
|
||
finally {
|
||
await conn.close();
|
||
}
|
||
valid = await this._integrityCheckFile(tempFile);
|
||
}
|
||
catch (e) {
|
||
if (!this.isCorruptionError(e)) {
|
||
throw e;
|
||
}
|
||
Zotero.logError(e);
|
||
}
|
||
if (!valid) {
|
||
// The WAL might be stale and not belong to the database file, so check
|
||
// whether the database file is valid on its own, and if so discard the WAL
|
||
Zotero.warn("Converted database failed integrity check "
|
||
+ "-- checking database file without WAL");
|
||
await IOUtils.remove(tempFile, { ignoreAbsent: true });
|
||
await IOUtils.remove(tempFile + '-wal', { ignoreAbsent: true });
|
||
await IOUtils.copy(file, tempFile);
|
||
await this._revertWALHeader(tempFile);
|
||
try {
|
||
valid = await this._integrityCheckFile(tempFile);
|
||
}
|
||
catch (e) {
|
||
if (!this.isCorruptionError(e)) {
|
||
throw e;
|
||
}
|
||
Zotero.logError(e);
|
||
}
|
||
if (!valid) {
|
||
throw new Error(this.DB_CORRUPTION_STRINGS[0]);
|
||
}
|
||
Zotero.warn("Database file is valid without WAL -- discarding WAL");
|
||
}
|
||
// Copy next to the original before replacing it so that the swap is atomic
|
||
await IOUtils.copy(tempFile, swapFile);
|
||
await IOUtils.move(swapFile, file);
|
||
}
|
||
finally {
|
||
await IOUtils.remove(tempFile, { ignoreAbsent: true });
|
||
await IOUtils.remove(tempFile + '-wal', { ignoreAbsent: true });
|
||
await IOUtils.remove(swapFile, { ignoreAbsent: true });
|
||
}
|
||
}
|
||
else if (isWALHeader) {
|
||
await this._revertWALHeader(file);
|
||
}
|
||
|
||
await IOUtils.remove(file + '-wal', { ignoreAbsent: true });
|
||
await IOUtils.remove(file + '-shm', { ignoreAbsent: true });
|
||
return true;
|
||
};
|
||
|
||
|
||
/**
|
||
* Set the write and read format versions in a database file's header to 1 (rollback journal)
|
||
*/
|
||
Zotero.DBConnection.prototype._revertWALHeader = async function (file) {
|
||
let stream = Components.classes["@mozilla.org/network/file-output-stream;1"]
|
||
.createInstance(Components.interfaces.nsIFileOutputStream);
|
||
// PR_WRONLY, no truncation
|
||
stream.init(Zotero.File.pathToFile(file), 0x02, 0o644, 0);
|
||
try {
|
||
stream.QueryInterface(Components.interfaces.nsISeekableStream)
|
||
.seek(Components.interfaces.nsISeekableStream.NS_SEEK_SET, 18);
|
||
stream.write("\x01\x01", 2);
|
||
}
|
||
finally {
|
||
stream.close();
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* @param {Error} e
|
||
* @param {Object} [options]
|
||
* @param {Boolean} [options.mainConfirmedCorrupt] - Skip the attached-database check because
|
||
* the caller already confirmed main-database corruption (e.g., with a full integrity
|
||
* check, which detects index inconsistencies that quick_check misses)
|
||
*/
|
||
Zotero.DBConnection.prototype._checkException = async function (e, { mainConfirmedCorrupt } = {}) {
|
||
if (this._externalDB || !this.isCorruptionError(e) || this._checkingCorruption
|
||
|| this._handlingCorruption) {
|
||
return true;
|
||
}
|
||
|
||
var progressToken = this._showProgressText('db-checking-integrity');
|
||
|
||
// A "malformed" error can come from an attached database (e.g., the rebuildable full-text
|
||
// index) rather than the main file, so confirm the main database is actually corrupt before
|
||
// offering to restore it. That way a disposable attached DB's corruption doesn't trigger
|
||
// main-database recovery; the attaching code detects and rebuilds it instead.
|
||
if (!mainConfirmedCorrupt) {
|
||
let mainOK = false;
|
||
this._checkingCorruption = true;
|
||
try {
|
||
mainOK = (await this.valueQueryAsync("PRAGMA main.quick_check(1)")) == 'ok';
|
||
}
|
||
catch (checkError) {
|
||
Zotero.logError(checkError);
|
||
// A check failure other than a corruption error leaves the state of the main
|
||
// database unknown, so don't start destructive recovery
|
||
if (!this.isCorruptionError(checkError)) {
|
||
this._clearProgressText(progressToken);
|
||
return true;
|
||
}
|
||
}
|
||
finally {
|
||
this._checkingCorruption = false;
|
||
}
|
||
if (mainOK) {
|
||
Zotero.logError(e);
|
||
Zotero.debug("Corruption error but the main database passed a check -- skipping "
|
||
+ "main-database recovery", 1);
|
||
// Let owners of attached databases (e.g., the full-text index) rebuild them. Deferred
|
||
// so it runs after the failing operation unwinds -- a rebuild may need to DETACH,
|
||
// which can't run inside the transaction the error may have come from.
|
||
for (let handler of this._corruptionHandlers) {
|
||
Zotero.Promise.delay(0).then(handler).catch(err => Zotero.logError(err));
|
||
}
|
||
this._clearProgressText(progressToken);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Corruption errors from other queries can keep arriving while this runs, since a pending
|
||
// restart or quit is asynchronous and execution continues until shutdown
|
||
this._handlingCorruption = true;
|
||
|
||
// Skip backups
|
||
this._dbIsCorrupt = true;
|
||
|
||
// If the database file is valid and only stale journal files are causing the corruption,
|
||
// save the verified copy to be swapped in at the next startup and restart
|
||
try {
|
||
if (await this._journalFilesExist() && await this._checkValidWithoutJournalFiles(true)) {
|
||
this._debug("Restarting to recover database from stale journal files", 1);
|
||
Zotero.skipLoading = true;
|
||
Zotero.Utilities.Internal.quit(true);
|
||
return false;
|
||
}
|
||
}
|
||
catch (e2) {
|
||
Zotero.logError(e2);
|
||
}
|
||
|
||
const supportURL = 'https://zotero.org/support/kb/corrupted_database';
|
||
|
||
var filename = PathUtils.filename(this._dbPath);
|
||
|
||
var backupDate = null;
|
||
var backupTime = null;
|
||
try {
|
||
let info = await OS.File.stat(this._dbPath + '.bak');
|
||
backupDate = info.lastModificationDate.toLocaleDateString();
|
||
backupTime = info.lastModificationDate.toLocaleTimeString();
|
||
Zotero.debug(`Found ${this._dbPath} with date of ${backupDate}`);
|
||
}
|
||
catch (e) {}
|
||
|
||
var ps = Services.prompt;
|
||
var buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
|
||
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL;
|
||
|
||
var index = ps.confirmEx(null,
|
||
Zotero.getString('general.error'),
|
||
Zotero.getString('db.dbCorrupted', [Zotero.appName, filename]) + '\n\n'
|
||
+ Zotero.getString('db.dbCorrupted.cloudStorage', Zotero.appName) + '\n\n'
|
||
+ (backupDate
|
||
? Zotero.getString(
|
||
'db.dbCorrupted.restoreFromLastAutomaticBackup',
|
||
[Zotero.appName, backupDate, backupTime]
|
||
) + '\n\n'
|
||
+ Zotero.getString('db.dbCorrupted.viewMoreInformation', supportURL)
|
||
: Zotero.getString('db.dbCorrupted.repairOrRestore', Zotero.appName)),
|
||
buttonFlags,
|
||
backupDate ? Zotero.getString('db.dbCorrupted.automaticBackup') : Zotero.getString('general.moreInformation'),
|
||
null,
|
||
null,
|
||
null, {});
|
||
|
||
if (index == 0) {
|
||
// Write corrupt marker to data directory
|
||
let file = Zotero.File.pathToFile(this._dbPath + '.is.corrupt');
|
||
Zotero.File.putContents(file, '');
|
||
Zotero.skipLoading = true;
|
||
Zotero.Utilities.Internal.quit(true);
|
||
}
|
||
else if (index == 1) {
|
||
this._handlingCorruption = false;
|
||
this._clearProgressText(progressToken);
|
||
}
|
||
else {
|
||
Zotero.launchURL(supportURL);
|
||
Zotero.Utilities.Internal.quit();
|
||
Zotero.skipLoading = true;
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
|
||
/**
|
||
* Move any -journal/-wal files along with a database file that's being moved
|
||
*
|
||
* SQLite associates journal files with a database by filename, so a stale journal left at the
|
||
* old path would be replayed into whatever file next occupies it (e.g., a restored backup),
|
||
* corrupting it. Moving the -wal file along with a .damaged file also keeps committed
|
||
* transactions that hadn't yet been checkpointed available for data recovery.
|
||
*/
|
||
Zotero.DBConnection.prototype._moveJournalFiles = async function (fromPath, toPath) {
|
||
for (let suffix of ['-journal', '-wal']) {
|
||
try {
|
||
if (await IOUtils.exists(fromPath + suffix)) {
|
||
this._debug(`Moving '${PathUtils.filename(fromPath + suffix)}' to `
|
||
+ `'${PathUtils.filename(toPath + suffix)}'`, 1);
|
||
await IOUtils.move(fromPath + suffix, toPath + suffix);
|
||
}
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
// A -shm file contains no recoverable data and is rebuilt by SQLite, so just remove it
|
||
try {
|
||
await IOUtils.remove(fromPath + '-shm', { ignoreAbsent: true });
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* Remove any journal files (-journal/-wal/-shm) at a database path so that they aren't
|
||
* replayed into a database file subsequently placed there
|
||
*
|
||
* @return {Boolean} - False if a -journal or -wal file couldn't be removed
|
||
*/
|
||
Zotero.DBConnection.prototype._removeJournalFiles = async function (dbPath) {
|
||
var success = true;
|
||
for (let suffix of ['-journal', '-wal', '-shm']) {
|
||
try {
|
||
if (await IOUtils.exists(dbPath + suffix)) {
|
||
this._debug(`Removing '${PathUtils.filename(dbPath + suffix)}'`, 1);
|
||
await IOUtils.remove(dbPath + suffix);
|
||
}
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
// A leftover -shm file on its own is harmless, so a failure to remove it
|
||
// shouldn't invalidate successful -journal/-wal removal
|
||
if (suffix != '-shm') {
|
||
success = false;
|
||
}
|
||
}
|
||
}
|
||
return success;
|
||
};
|
||
|
||
|
||
/**
|
||
* Check whether the database file is valid without its journal files by copying it to a
|
||
* temporary path, which strips the journal association, and running an integrity check on
|
||
* the copy
|
||
*
|
||
* A database and WAL pair that belong together and are both intact don't produce corruption
|
||
* errors, so a database that's valid without its WAL while the combined view is corrupt means
|
||
* the WAL doesn't belong to it.
|
||
*
|
||
* @param {Boolean} [keepCopy] - Keep a verified copy at <database>.repair.tmp, to be swapped
|
||
* in by _applyPendingRepair() at the next startup
|
||
* @return {Boolean}
|
||
*/
|
||
Zotero.DBConnection.prototype._checkValidWithoutJournalFiles = async function (keepCopy) {
|
||
var file = this._dbPath;
|
||
|
||
if (!(await IOUtils.exists(file))) {
|
||
return false;
|
||
}
|
||
|
||
this._debug(`Checking whether database file '${PathUtils.filename(file)}' is valid `
|
||
+ `without its journal files`, 1);
|
||
var tmpFile = file + (keepCopy ? '.repair.tmp' : '.check.tmp');
|
||
var valid = false;
|
||
try {
|
||
await IOUtils.remove(tmpFile, { ignoreAbsent: true });
|
||
await IOUtils.remove(tmpFile + '.verified', { ignoreAbsent: true });
|
||
// Remove journal files from an earlier interrupted check, which would otherwise be
|
||
// replayed into the new copy and invalidate the check
|
||
if (!(await this._removeJournalFiles(tmpFile))) {
|
||
throw new Error("Couldn't remove journal files of previous temporary copy");
|
||
}
|
||
await Zotero.File.copyFile(file, tmpFile);
|
||
valid = await this._integrityCheckFile(tmpFile);
|
||
// Record the verified copy's size and mtime so that _applyPendingRepair() can skip
|
||
// another integrity check after the restart. A crash before verification finishes
|
||
// leaves no record, so the copy gets checked again at startup.
|
||
if (valid && keepCopy) {
|
||
let { size, lastModified } = await IOUtils.stat(tmpFile);
|
||
await Zotero.File.putContentsAsync(
|
||
tmpFile + '.verified', JSON.stringify({ size, lastModified })
|
||
);
|
||
}
|
||
}
|
||
catch (e) {
|
||
// Only a corruption error marks the database file as invalid -- operational errors
|
||
// (I/O, permissions) propagate so that recovery is aborted rather than proceeding
|
||
// destructively
|
||
if (!this.isCorruptionError(e)) {
|
||
throw e;
|
||
}
|
||
Zotero.logError(e);
|
||
}
|
||
finally {
|
||
if (!valid || !keepCopy) {
|
||
try {
|
||
await IOUtils.remove(tmpFile, { ignoreAbsent: true });
|
||
await IOUtils.remove(tmpFile + '.verified', { ignoreAbsent: true });
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
}
|
||
this._debug(valid
|
||
? "Database file is valid without its journal files"
|
||
: "Database file isn't valid on its own", 1);
|
||
return valid;
|
||
};
|
||
|
||
|
||
/**
|
||
* Show a message on the main-window progress meter for a potentially long-running check or
|
||
* repair, ignoring errors (e.g., if no window exists yet)
|
||
*
|
||
* @return {Object|null} - Token for _clearProgressText()
|
||
*/
|
||
Zotero.DBConnection.prototype._showProgressText = function (l10nID) {
|
||
try {
|
||
return Zotero.showZoteroPaneProgressMeter(Zotero.ftl.formatValueSync(l10nID));
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* Restore the progress display shown with _showProgressText() to its previous state, leaving
|
||
* it alone if another operation has changed it since
|
||
*/
|
||
Zotero.DBConnection.prototype._clearProgressText = function (token) {
|
||
try {
|
||
Zotero.restoreZoteroPaneProgressMeter(token);
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
};
|
||
|
||
|
||
/**
|
||
* Check whether journal files that can affect database contents (-journal/-wal) exist. A
|
||
* stale -shm file on its own is harmless, since the WAL index is just rebuilt from the
|
||
* -wal file.
|
||
*/
|
||
Zotero.DBConnection.prototype._journalFilesExist = async function () {
|
||
return await IOUtils.exists(this._dbPath + '-journal')
|
||
|| await IOUtils.exists(this._dbPath + '-wal');
|
||
};
|
||
|
||
|
||
/**
|
||
* Run an integrity check on a database file over a separate connection
|
||
*
|
||
* @param {String} path
|
||
* @param {Boolean} [quick] - Run quick_check instead of a full integrity_check, skipping
|
||
* verification of index contents against tables
|
||
* @return {Boolean}
|
||
*/
|
||
Zotero.DBConnection.prototype._integrityCheckFile = async function (path, quick) {
|
||
var connection = await this.Sqlite.openConnection({ path, openNotExclusive: Zotero.isMac });
|
||
var ok;
|
||
try {
|
||
try {
|
||
let rows = await connection.execute(
|
||
`PRAGMA ${quick ? 'quick_check' : 'integrity_check'}(1)`
|
||
);
|
||
ok = !!rows.length && rows[0].getResultByIndex(0) == 'ok';
|
||
}
|
||
finally {
|
||
await connection.close();
|
||
}
|
||
}
|
||
finally {
|
||
// Remove journal files that opening the database can leave behind, even if the check
|
||
// throws. Fail on a removal failure only if the check passed, so that an original
|
||
// corruption error isn't masked.
|
||
if (!(await this._removeJournalFiles(path)) && ok) {
|
||
throw new Error("Couldn't remove journal files left by integrity check");
|
||
}
|
||
}
|
||
return ok;
|
||
};
|
||
|
||
|
||
/**
|
||
* Copy a database file whose only damage is index inconsistencies -- one that fails a full
|
||
* integrity check but passes a quick check -- and rebuild its indexes with REINDEX, which
|
||
* recreates them from table contents
|
||
*
|
||
* @return {String|false} - Path of the repaired copy if it passes a full integrity check,
|
||
* or false if the file can't be repaired this way (e.g., table data contains actual
|
||
* UNIQUE violations, which cause REINDEX to fail)
|
||
*/
|
||
Zotero.DBConnection.prototype._reindexToCopy = async function (path) {
|
||
var tmpFile = path + '.reindex.tmp';
|
||
try {
|
||
await IOUtils.remove(tmpFile, { ignoreAbsent: true });
|
||
// A leftover journal file from an earlier interrupted repair would be replayed into
|
||
// the new copy, so fail on it as an operational error
|
||
if (!(await this._removeJournalFiles(tmpFile))) {
|
||
throw new Error("Couldn't remove journal files of previous reindex copy");
|
||
}
|
||
await Zotero.File.copyFile(path, tmpFile);
|
||
this._debug(`Rebuilding indexes of '${PathUtils.filename(tmpFile)}'`, 1);
|
||
this._showProgressText('db-repairing');
|
||
let connection = await this.Sqlite.openConnection({ path: tmpFile, openNotExclusive: Zotero.isMac });
|
||
try {
|
||
try {
|
||
await connection.execute("REINDEX");
|
||
}
|
||
finally {
|
||
await connection.close();
|
||
}
|
||
}
|
||
finally {
|
||
await this._removeJournalFiles(tmpFile);
|
||
}
|
||
if (await this._integrityCheckFile(tmpFile)) {
|
||
return tmpFile;
|
||
}
|
||
}
|
||
catch (e) {
|
||
// Constraint and corruption errors mean the file can't be repaired this way --
|
||
// operational errors propagate so that recovery is aborted
|
||
if (!this.isCorruptionError(e) && !/constraint/i.test(e.message ?? '')) {
|
||
try {
|
||
await IOUtils.remove(tmpFile, { ignoreAbsent: true });
|
||
}
|
||
catch (e2) {
|
||
Zotero.logError(e2);
|
||
}
|
||
throw e;
|
||
}
|
||
Zotero.logError(e);
|
||
}
|
||
this._debug("Indexes couldn't be rebuilt", 1);
|
||
try {
|
||
await IOUtils.remove(tmpFile, { ignoreAbsent: true });
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
|
||
/**
|
||
* Replace the database file with a verified copy saved by _checkException() before a restart,
|
||
* and remove the stale journal files that caused the corruption
|
||
*
|
||
* The saved copy is swapped in at startup rather than the journal files being removed at
|
||
* detection time, since SQLite can checkpoint stale WAL data into the database file when the
|
||
* connection is closed.
|
||
*
|
||
* Also removes temporary files left behind by interrupted checks and repairs.
|
||
*/
|
||
Zotero.DBConnection.prototype._applyPendingRepair = async function () {
|
||
var file = this._dbPath;
|
||
var repairFile = file + '.repair.tmp';
|
||
var verifiedFile = repairFile + '.verified';
|
||
|
||
// Clean up temporary files left behind by an interrupted check or repair
|
||
for (let tmpFile of [file + '.check.tmp', file + '.bak.reindex.tmp']) {
|
||
try {
|
||
await IOUtils.remove(tmpFile, { ignoreAbsent: true });
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
await this._removeJournalFiles(tmpFile);
|
||
}
|
||
|
||
if (!(await IOUtils.exists(repairFile))) {
|
||
try {
|
||
await IOUtils.remove(verifiedFile, { ignoreAbsent: true });
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
await this._removeJournalFiles(repairFile);
|
||
return;
|
||
}
|
||
|
||
// If the copy still matches the size and mtime recorded after verification, skip another
|
||
// integrity check
|
||
var valid = false;
|
||
try {
|
||
let { size, lastModified } = JSON.parse(
|
||
await Zotero.File.getContentsAsync(verifiedFile)
|
||
);
|
||
let info = await IOUtils.stat(repairFile);
|
||
valid = info.size === size && info.lastModified === lastModified;
|
||
}
|
||
catch (e) {}
|
||
// Otherwise verify the copy before replacing the database file with it
|
||
if (!valid) {
|
||
this._showProgressText('db-checking-integrity');
|
||
try {
|
||
valid = await this._integrityCheckFile(repairFile);
|
||
}
|
||
catch (e) {
|
||
// Only a corruption error marks the copy as invalid for removal -- operational
|
||
// errors (I/O, permissions) propagate so that startup is aborted and the repair
|
||
// file preserved
|
||
if (!this.isCorruptionError(e)) {
|
||
throw e;
|
||
}
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
await IOUtils.remove(verifiedFile, { ignoreAbsent: true });
|
||
if (!valid) {
|
||
this._debug(`Removing invalid repair file '${PathUtils.filename(repairFile)}'`, 1);
|
||
await IOUtils.remove(repairFile, { ignoreAbsent: true });
|
||
return;
|
||
}
|
||
|
||
this._debug(`Replacing '${PathUtils.filename(file)}' with verified copy saved before `
|
||
+ `restart`, 1);
|
||
// A journal file that can't be removed would be replayed into the repaired database, so
|
||
// fail instead
|
||
if (!(await this._removeJournalFiles(file))) {
|
||
throw new Error("Couldn't remove stale journal files -- not applying pending repair");
|
||
}
|
||
await IOUtils.move(repairFile, file);
|
||
await IOUtils.remove(file + '.is.corrupt', { ignoreAbsent: true });
|
||
};
|
||
|
||
|
||
/**
|
||
* If the database file is valid on its own and stale journal files are causing the corruption
|
||
* -- e.g., a -wal file left behind by a force-quit that no longer matches the database file
|
||
* because the latter was manually replaced from a backup -- remove the journal files so that
|
||
* the database file can be used as is.
|
||
*
|
||
* Also returns true for a valid database file with no journal files, which can result from an
|
||
* earlier recovery that was interrupted before the corruption marker was cleared, so that the
|
||
* file isn't needlessly replaced with an older backup.
|
||
*
|
||
* @return {Boolean} - True if the database file is valid and any journal files were removed
|
||
*/
|
||
Zotero.DBConnection.prototype._recoverFromStaleJournalFiles = async function () {
|
||
if (!(await this._checkValidWithoutJournalFiles())) {
|
||
return false;
|
||
}
|
||
// A journal file that can't be removed would be replayed into the database file as soon
|
||
// as it was opened, so fail instead
|
||
if (!(await this._removeJournalFiles(this._dbPath))) {
|
||
throw new Error("Couldn't remove stale journal files");
|
||
}
|
||
return true;
|
||
};
|
||
|
||
|
||
/**
|
||
* @return {Boolean} - True if recovered, false if not
|
||
*/
|
||
Zotero.DBConnection.prototype._handleCorruptionMarker = async function () {
|
||
var file = this._dbPath;
|
||
var fileName = PathUtils.filename(file);
|
||
var backupFile = this._dbPath + '.bak';
|
||
var corruptMarker = this._dbPath + '.is.corrupt';
|
||
|
||
this._debug(`Database file '${fileName}' corrupted`, 1);
|
||
|
||
this._showProgressText('db-checking-integrity');
|
||
|
||
// If the database file is valid and only stale journal files are causing the corruption,
|
||
// keep it and skip the backup restore
|
||
if (await this._recoverFromStaleJournalFiles()) {
|
||
this._connection = await Promise.resolve(this.Sqlite.openConnection({
|
||
path: file,
|
||
openNotExclusive: Zotero.isMac
|
||
}));
|
||
this._debug('Database recovered from stale journal files', 1);
|
||
if (await OS.File.exists(corruptMarker)) {
|
||
await OS.File.remove(corruptMarker);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// No backup file! Eek!
|
||
if (!(await OS.File.exists(backupFile))) {
|
||
this._debug("No backup file for DB '" + this._dbName + "' exists", 1);
|
||
|
||
let damagedFile;
|
||
|
||
// If database file exists, move it to .damaged
|
||
if (await OS.File.exists(file)) {
|
||
this._debug('Saving damaged DB file with .damaged extension', 1);
|
||
damagedFile = this._dbPath + '.damaged';
|
||
damagedFile = await Zotero.File.moveToUnique(file, damagedFile);
|
||
await this._moveJournalFiles(file, damagedFile);
|
||
}
|
||
// If it doesn't exist, assume we already showed a warning and moved it
|
||
else {
|
||
this._debug(`Database file '${fileName}' doesn't exist!`);
|
||
}
|
||
|
||
// A journal file that can't be removed would be replayed into the new database, so
|
||
// fail instead
|
||
if (!(await this._removeJournalFiles(file))) {
|
||
throw new Error("Couldn't remove stale journal files");
|
||
}
|
||
|
||
// Create new main database
|
||
this._connection = await Promise.resolve(this.Sqlite.openConnection({
|
||
path: file,
|
||
openNotExclusive: Zotero.isMac
|
||
}));
|
||
|
||
if (await OS.File.exists(corruptMarker)) {
|
||
await OS.File.remove(corruptMarker);
|
||
}
|
||
|
||
if (damagedFile) {
|
||
Zotero.alert(
|
||
null,
|
||
Zotero.getString('startupError', Zotero.appName),
|
||
Zotero.getString(
|
||
'db.dbCorruptedNoBackup',
|
||
[Zotero.appName, fileName, PathUtils.filename(damagedFile)]
|
||
)
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Save damaged file, unless it was already moved by an interrupted earlier recovery
|
||
var damagedFile = this._dbPath + '.damaged';
|
||
if (await OS.File.exists(file)) {
|
||
this._debug('Saving damaged DB file with .damaged extension', 1);
|
||
damagedFile = await Zotero.File.moveToUnique(file, damagedFile);
|
||
await this._moveJournalFiles(file, damagedFile);
|
||
}
|
||
|
||
// Check the backup file
|
||
var backupValid = false;
|
||
var restoreFile = backupFile;
|
||
try {
|
||
// Remove any stale backup journal files so that they aren't replayed into the
|
||
// backup when it's opened
|
||
if (!(await this._removeJournalFiles(backupFile))) {
|
||
throw new Error("Couldn't remove backup journal files");
|
||
}
|
||
this._debug(`Checking integrity of '${PathUtils.filename(backupFile)}'`, 1);
|
||
backupValid = await this._integrityCheckFile(backupFile);
|
||
// If the backup fails only the index checks that a quick check skips, rebuild the
|
||
// indexes on a copy and restore that instead
|
||
if (!backupValid && (await this._integrityCheckFile(backupFile, true))) {
|
||
let repairedFile = await this._reindexToCopy(backupFile);
|
||
if (repairedFile) {
|
||
restoreFile = repairedFile;
|
||
backupValid = true;
|
||
}
|
||
}
|
||
}
|
||
catch (e) {
|
||
// Only a corruption error marks the backup as invalid -- operational errors
|
||
// propagate so that recovery can be retried
|
||
if (!this.isCorruptionError(e)) {
|
||
throw e;
|
||
}
|
||
Zotero.logError(e);
|
||
}
|
||
// Backup is corrupt too
|
||
if (!backupValid) {
|
||
if (!(await this._removeJournalFiles(file))) {
|
||
throw new Error("Couldn't remove stale journal files");
|
||
}
|
||
|
||
// Create new main database
|
||
this._connection = await Promise.resolve(this.Sqlite.openConnection({
|
||
path: file,
|
||
openNotExclusive: Zotero.isMac
|
||
}));
|
||
|
||
Zotero.alert(
|
||
null,
|
||
Zotero.getString('general.error'),
|
||
Zotero.getString(
|
||
'db.dbRestoreFailed',
|
||
[Zotero.appName, fileName, PathUtils.filename(damagedFile)]
|
||
)
|
||
);
|
||
|
||
if (await OS.File.exists(corruptMarker)) {
|
||
await OS.File.remove(corruptMarker);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// Copy backup file to main DB file
|
||
this._debug("Restoring database '" + this._dbName + "' from backup file", 1);
|
||
try {
|
||
// A journal file that can't be removed would be replayed into the restored database,
|
||
// so fail instead
|
||
if (!(await this._removeJournalFiles(file))) {
|
||
throw new Error("Couldn't remove stale journal files");
|
||
}
|
||
await Zotero.File.copyFile(restoreFile, file);
|
||
}
|
||
catch (e) {
|
||
// TODO: deal with low disk space
|
||
throw e;
|
||
}
|
||
finally {
|
||
if (restoreFile != backupFile) {
|
||
try {
|
||
await IOUtils.remove(restoreFile, { ignoreAbsent: true });
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Open restored database
|
||
this._connection = await Promise.resolve(this.Sqlite.openConnection({
|
||
path: file,
|
||
openNotExclusive: Zotero.isMac
|
||
}));
|
||
this._debug('Database restored', 1);
|
||
let backupDate = '';
|
||
let backupTime = '';
|
||
try {
|
||
let info = await OS.File.stat(backupFile);
|
||
backupDate = info.lastModificationDate.toLocaleDateString();
|
||
backupTime = info.lastModificationDate.toLocaleTimeString();
|
||
}
|
||
catch (e) {
|
||
Zotero.logError(e);
|
||
}
|
||
Zotero.alert(
|
||
null,
|
||
Zotero.getString('general.warning'),
|
||
Zotero.getString(
|
||
'db.dbRestored',
|
||
[Zotero.appName, fileName, backupDate, backupTime, PathUtils.filename(damagedFile)]
|
||
) + '\n\n'
|
||
+ Zotero.getString('db.dbRestored.cloudStorage')
|
||
);
|
||
|
||
if (await OS.File.exists(corruptMarker)) {
|
||
await OS.File.remove(corruptMarker);
|
||
}
|
||
};
|
||
|
||
|
||
Zotero.DBConnection.prototype._debug = function (str, level) {
|
||
var prefix = this._dbName == 'zotero' ? '' : '[' + this._dbName + '] ';
|
||
Zotero.debug(prefix + str, level);
|
||
}
|
||
|
||
Zotero.DBConnection.TimeoutError = class TimeoutError extends Error {
|
||
constructor(message) {
|
||
super(message);
|
||
this.name = 'TimeoutError';
|
||
}
|
||
};
|