www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

db.js (36843B)


      1 /*
      2     ***** BEGIN LICENSE BLOCK *****
      3     
      4     Copyright © 2009 Center for History and New Media
      5                      George Mason University, Fairfax, Virginia, USA
      6                      http://zotero.org
      7     
      8     This file is part of Zotero.
      9     
     10     Zotero is free software: you can redistribute it and/or modify
     11     it under the terms of the GNU Affero General Public License as published by
     12     the Free Software Foundation, either version 3 of the License, or
     13     (at your option) any later version.
     14     
     15     Zotero is distributed in the hope that it will be useful,
     16     but WITHOUT ANY WARRANTY; without even the implied warranty of
     17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     18     GNU Affero General Public License for more details.
     19     
     20     You should have received a copy of the GNU Affero General Public License
     21     along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
     22     
     23     ***** END LICENSE BLOCK *****
     24 */
     25 
     26 "use strict";
     27 
     28 // Exclusive locking mode (default) prevents access to Zotero database while Zotero is open
     29 // and speeds up DB access (http://www.sqlite.org/pragma.html#pragma_locking_mode).
     30 // Normal mode is more convenient for development, but risks database corruption, particularly if
     31 // the same database is accessed simultaneously by multiple Zotero instances.
     32 const DB_LOCK_EXCLUSIVE = true;
     33 
     34 Zotero.DBConnection = function(dbNameOrPath) {
     35 	if (!dbNameOrPath) {
     36 		throw ('DB name not provided in Zotero.DBConnection()');
     37 	}
     38 	
     39 	this.MAX_BOUND_PARAMETERS = 999;
     40 	this.DB_CORRUPTION_STRING = "2152857611";
     41 	
     42 	Components.utils.import("resource://gre/modules/Sqlite.jsm", this);
     43 	
     44 	this.closed = false;
     45 	this.skipBackup = false;
     46 	
     47 	// JS Date
     48 	this.__defineGetter__('transactionDate', function () {
     49 		if (this._transactionDate) {
     50 			this._lastTransactionDate = this._transactionDate;
     51 			return this._transactionDate;
     52 		}
     53 		
     54 		throw new Error("Transaction not in progress");
     55 		
     56 		// Use second granularity rather than millisecond
     57 		// for comparison purposes
     58 		var d = new Date(Math.floor(new Date / 1000) * 1000);
     59 		this._lastTransactionDate = d;
     60 		return d;
     61 	});
     62 	// SQL DATETIME
     63 	this.__defineGetter__('transactionDateTime', function () {
     64 		var d = this.transactionDate;
     65 		return Zotero.Date.dateToSQL(d, true);
     66 	});
     67 	// Unix timestamp
     68 	this.__defineGetter__('transactionTimestamp', function () {
     69 		var d = this.transactionDate;
     70 		return Zotero.Date.toUnixTimestamp(d);
     71 	});
     72 	
     73 	// Absolute path to DB
     74 	if (dbNameOrPath.startsWith('/') || (Zotero.isWin && dbNameOrPath.includes('\\'))) {
     75 		this._dbName = OS.Path.basename(dbNameOrPath).replace(/\.sqlite$/, '');
     76 		this._dbPath = dbNameOrPath;
     77 		this._externalDB = true;
     78 	}
     79 	// DB name in data directory
     80 	else {
     81 		this._dbName = dbNameOrPath;
     82 		this._dbPath = Zotero.DataDirectory.getDatabase(dbNameOrPath);
     83 		this._externalDB = false;
     84 	}
     85 	this._shutdown = false;
     86 	this._connection = null;
     87 	this._transactionID = null;
     88 	this._transactionDate = null;
     89 	this._lastTransactionDate = null;
     90 	this._transactionRollback = false;
     91 	this._transactionNestingLevel = 0;
     92 	this._callbacks = {
     93 		begin: [],
     94 		commit: [],
     95 		rollback: [],
     96 		current: {
     97 			commit: [],
     98 			rollback: []
     99 		}
    100 	};
    101 	this._dbIsCorrupt = null
    102 	
    103 	this._transactionPromise = null;
    104 	
    105 	if (dbNameOrPath == 'zotero') {
    106 		this.IncompatibleVersionException = function (msg, dbClientVersion) {
    107 			this.message = msg;
    108 			this.dbClientVersion = dbClientVersion;
    109 		}
    110 		this.IncompatibleVersionException.prototype = Object.create(Error.prototype);
    111 	}
    112 }
    113 
    114 /////////////////////////////////////////////////////////////////
    115 //
    116 // Public methods
    117 //
    118 /////////////////////////////////////////////////////////////////
    119 
    120 /**
    121  * Test a read-only connection to the database, throwing any errors that occur
    122  *
    123  * @return	void
    124  */
    125 Zotero.DBConnection.prototype.test = function () {
    126 	return this._getConnectionAsync().then(() => {});
    127 }
    128 
    129 Zotero.DBConnection.prototype.getAsyncStatement = Zotero.Promise.coroutine(function* (sql) {
    130 	var conn = yield this._getConnectionAsync();
    131 	conn = conn._connection;
    132 	
    133 	try {
    134 		this._debug(sql, 4);
    135 		return conn.createAsyncStatement(sql);
    136 	}
    137 	catch (e) {
    138 		var dberr = (conn.lastErrorString != 'not an error')
    139 			? ' [ERROR: ' + conn.lastErrorString + ']' : '';
    140 		throw new Error(e + ' [QUERY: ' + sql + ']' + dberr); 
    141 	}
    142 });
    143 
    144 
    145 Zotero.DBConnection.prototype.parseQueryAndParams = function (sql, params) {
    146 	// If single scalar value, wrap in an array
    147 	if (!Array.isArray(params)) {
    148 		if (typeof params == 'string' || typeof params == 'number' || typeof params == 'object'
    149 				|| params === null) {
    150 			params = [params];
    151 		}
    152 		else {
    153 			params = [];
    154 		}
    155 	}
    156 	// Otherwise, since we might make changes, only work on a copy of the array
    157 	else {
    158 		params = params.concat();
    159 	}
    160 	
    161 	// Find placeholders
    162 	if (params.length) {
    163 		let matches = sql.match(/\?\d*/g);
    164 		if (!matches) {
    165 			throw new Error("Parameters provided for query without placeholders "
    166 				+ "[QUERY: " + sql + "]");
    167 		}
    168 		else {
    169 			// Count numbered parameters (?1) properly
    170 			let num = 0;
    171 			let numbered = {};
    172 			for (let i = 0; i < matches.length; i++) {
    173 				let match = matches[i];
    174 				if (match == '?') {
    175 					num++;
    176 				}
    177 				else {
    178 					numbered[match] = true;
    179 				}
    180 			}
    181 			num += Object.keys(numbered).length;
    182 			
    183 			if (params.length != num) {
    184 				throw new Error("Incorrect number of parameters provided for query "
    185 					+ "(" + params.length + ", expecting " + num + ") "
    186 					+ "[QUERY: " + sql + "]");
    187 			}
    188 		}
    189 		
    190 		// First, determine the type of query using first word
    191 		let queryMethod = sql.match(/^[^\s\(]*/)[0].toLowerCase();
    192 		
    193 		// Reset lastIndex, since regexp isn't recompiled dynamically
    194 		let placeholderRE = /\s*[=,(]\s*\?/g;
    195 		for (var i=0; i<params.length; i++) {
    196 			// Find index of this parameter, skipping previous ones
    197 			matches = placeholderRE.exec(sql);
    198 			
    199 			if (typeof params[i] == 'boolean') {
    200 				throw new Error("Invalid boolean parameter " + i + " '" + params[i] + "' "
    201 					+ "[QUERY: " + sql + "]");
    202 			}
    203 			else if (params[i] === undefined) {
    204 				throw new Error('Parameter ' + i + ' is undefined [QUERY: ' + sql + ']');
    205 			}
    206 			
    207 			if (params[i] !== null) {
    208 				// Force parameter type if specified
    209 				
    210 				// Int
    211 				if (typeof params[i]['int'] != 'undefined') {
    212 					params[i] = parseInt(params[i]['int']);
    213 					if (isNaN(params[i])) {
    214 						throw new Error("Invalid bound parameter " + i + " integer value '" + params[i] + "' "
    215 							+ "[QUERY: " + sql + "]")
    216 					}
    217 				}
    218 				// String
    219 				else if (typeof params[i]['string'] != 'undefined') {
    220 					params[i] = params[i]['string'] + "";
    221 				}
    222 				
    223 				continue;
    224 			}
    225 			
    226 			//
    227 			// Replace NULL bound parameters with hard-coded NULLs
    228 			//
    229 			if (!matches) {
    230 				throw new Error("Null parameter provided for a query without placeholders "
    231 					+ "-- use false or undefined [QUERY: " + sql + "]");
    232 			}
    233 			
    234 			if (matches[0].trim().indexOf('=') == -1) {
    235 				if (queryMethod == 'select') {
    236 					throw new Error("NULL cannot be used for parenthesized placeholders "
    237 						+ "in SELECT queries [QUERY: " + sql + "]");
    238 				}
    239 				var repl = matches[0].replace('?', 'NULL');
    240 			}
    241 			else if (queryMethod == 'select') {
    242 				var repl = ' IS NULL';
    243 			}
    244 			else {
    245 				var repl = '=NULL';
    246 			}
    247 			
    248 			var subpos = matches.index;
    249 			var sublen = matches[0].length;
    250 			sql = sql.substring(0, subpos) + repl + sql.substr(subpos + sublen);
    251 			
    252 			//Zotero.debug("Hard-coding null bound parameter " + i);
    253 			
    254 			params.splice(i, 1);
    255 			i--;
    256 		}
    257 		if (!params.length) {
    258 			params = [];
    259 		}
    260 	}
    261 	else if (/\?/g.test(sql)) {
    262 		throw new Error("Parameters not provided for query containing placeholders "
    263 			+ "[QUERY: " + sql + "]");
    264 	}
    265 	
    266 	return [sql, params];
    267 };
    268 
    269 
    270 /**
    271  * Execute an asynchronous statement with previously bound parameters
    272  *
    273  * Warning: This will freeze if used with a write statement within executeTransaction()!
    274  *
    275  * @param {mozIStorageAsyncStatement} statement - Statement to run
    276  * @param {Function} [progressHandler] - Function to pass each available row to for SELECT queries
    277  * @return {Promise} - Resolved on completion, rejected with a reason on error
    278  */
    279 Zotero.DBConnection.prototype.executeAsyncStatement = Zotero.Promise.method(function (statement, progressHandler) {
    280 	var resolve;
    281 	var reject;
    282 	statement.executeAsync({
    283 		handleResult: function (resultSet) {
    284 			if (progressHandler) {
    285 				progressHandler(resultSet.getNextRow());
    286 			}
    287 		},
    288 		
    289 		handleError: function (e) {
    290 			reject(e);
    291 		},
    292 		
    293 		handleCompletion: function (reason) {
    294 			if (reason != Components.interfaces.mozIStorageStatementCallback.REASON_FINISHED) {
    295 				reject(reason);
    296 			}
    297 			resolve();
    298 		}
    299 	});
    300 	return new Zotero.Promise(function () {
    301 		resolve = arguments[0];
    302 		reject = arguments[1];
    303 	});
    304 });
    305 
    306 
    307 
    308 Zotero.DBConnection.prototype.addCallback = function (type, cb) {
    309 	switch (type) {
    310 		case 'begin':
    311 		case 'commit':
    312 		case 'rollback':
    313 			break;
    314 			
    315 		default:
    316 			throw ("Invalid callback type '" + type + "' in DB.addCallback()");
    317 	}
    318 	
    319 	var id = this._callbacks[type].length;
    320 	this._callbacks[type][id] = cb;
    321 	return id;
    322 }
    323 
    324 
    325 Zotero.DBConnection.prototype.addCurrentCallback = function (type, cb) {
    326 	this.requireTransaction();
    327 	this._callbacks.current[type].push(cb);
    328 }
    329 
    330 
    331 Zotero.DBConnection.prototype.removeCallback = function (type, id) {
    332 	switch (type) {
    333 		case 'begin':
    334 		case 'commit':
    335 		case 'rollback':
    336 			break;
    337 			
    338 		default:
    339 			throw ("Invalid callback type '" + type + "' in DB.removeCallback()");
    340 	}
    341 	
    342 	delete this._callbacks[type][id];
    343 }
    344 
    345 
    346 /*
    347  * Used on shutdown to rollback all open transactions
    348  *
    349  * TODO: update or remove
    350  */
    351 Zotero.DBConnection.prototype.rollbackAllTransactions = function () {
    352 	if (this.transactionInProgress()) {
    353 		var level = this._transactionNestingLevel;
    354 		this._transactionNestingLevel = 0;
    355 		try {
    356 			this.rollbackTransaction();
    357 		}
    358 		catch (e) {}
    359 		return level ? level : true;
    360 	}
    361 	return false;
    362 }
    363 
    364 
    365 Zotero.DBConnection.prototype.getColumns = function (table) {
    366 	return this.queryAsync("PRAGMA table_info(" + table + ")")
    367 	.then(function (rows) {
    368 		return rows.map(row => row.name);
    369 	})
    370 	.catch(function (e) {
    371 		this._debug(e, 1);
    372 		return false;
    373 	});
    374 }
    375 
    376 
    377 /**
    378 * Find the next lowest numeric suffix for a value in table column
    379 *
    380 * For example, if "Untitled" and "Untitled 2" and "Untitled 4",
    381 * returns "Untitled 3"
    382 *
    383 * If _name_ alone is available, returns that
    384 **/
    385 Zotero.DBConnection.prototype.getNextName = Zotero.Promise.coroutine(function* (libraryID, table, field, name)
    386 {
    387 	if (typeof name == 'undefined') {
    388 		Zotero.debug("WARNING: The parameters of Zotero.DB.getNextName() have changed -- update your code", 2);
    389 		[libraryID, table, field, name] = [null, libraryID, table, field];
    390 	}
    391 	
    392 	var sql = "SELECT SUBSTR(" + field + ", " + (name.length + 1) + ") FROM " + table
    393 		+ " WHERE libraryID=? AND " + field + " LIKE ? ORDER BY " + field;
    394 	var params = [libraryID, name + "%"];
    395 	var suffixes = yield this.columnQueryAsync(sql, params);
    396 	suffixes.filter(x => x.match(/^( [0-9]+)?$/));
    397 	
    398 	// If none found or first one has a suffix, use default name
    399 	if (!suffixes.length || suffixes[0]) {
    400 		return name;
    401 	}
    402 	
    403 	suffixes.sort(function (a, b) {
    404 		return parseInt(a) - parseInt(b);
    405 	});
    406 	
    407 	var i = 1;
    408 	while (suffixes[i] === "") {
    409 		i++;
    410 	}
    411 	var num = 2;
    412 	while (suffixes[i] == num) {
    413 		while (suffixes[i+1] && suffixes[i] == suffixes[i+1]) {
    414 			i++;
    415 		}
    416 		i++;
    417 		num++;
    418 	}
    419 	return name + ' ' + num;
    420 });
    421 
    422 
    423 //
    424 // Async methods
    425 //
    426 //
    427 // Zotero.DB.executeTransaction(function* (conn) {
    428 //     var created = yield Zotero.DB.queryAsync("CREATE TEMPORARY TABLE tmpFoo (foo TEXT, bar INT)");
    429 //     
    430 //     // created == true
    431 //     
    432 //     var result = yield Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('a', ?)", 1);
    433 //     
    434 //     // result == 1
    435 //     
    436 //     yield Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('b', 2)");
    437 //     yield Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('c', 3)");
    438 //     yield Zotero.DB.queryAsync("INSERT INTO tmpFoo VALUES ('d', 4)");
    439 //     
    440 //     var value = yield Zotero.DB.valueQueryAsync("SELECT foo FROM tmpFoo WHERE bar=?", 2);
    441 //     
    442 //     // value == "b"
    443 //     
    444 //     var vals = yield Zotero.DB.columnQueryAsync("SELECT foo FROM tmpFoo");
    445 //     
    446 //     // '0' => "a"
    447 //     // '1' => "b"
    448 //     // '2' => "c"
    449 //     // '3' => "d"
    450 //     
    451 //     let rows = yield Zotero.DB.queryAsync("SELECT * FROM tmpFoo");
    452 //     for (let i=0; i<rows.length; i++) {
    453 //         let row = rows[i];
    454 //         // row.foo == 'a', row.bar == 1
    455 //         // row.foo == 'b', row.bar == 2
    456 //         // row.foo == 'c', row.bar == 3
    457 //         // row.foo == 'd', row.bar == 4
    458 //     }
    459 //     
    460 //    return rows;
    461 // })
    462 // .then(function (rows) {
    463 //     // rows == same as above
    464 // });
    465 //
    466 /**
    467  * @param {Function} func - Generator function that yields promises,
    468  *                          generally from queryAsync() and similar
    469  * @return {Promise} - Promise for result of generator function
    470  */
    471 Zotero.DBConnection.prototype.executeTransaction = Zotero.Promise.coroutine(function* (func, options) {
    472 	options = options || {};
    473 	var resolve;
    474 	
    475 	// Set temporary options for this transaction that will be reset at the end
    476 	var origOptions = {};
    477 	if (options) {
    478 		for (let option in options) {
    479 			origOptions[option] = this[option];
    480 			this[option] = options[option];
    481 		}
    482 	}
    483 	
    484 	var startedTransaction = false;
    485 	var id = Zotero.Utilities.randomString();
    486 	
    487 	try {
    488 		while (this._transactionID) {
    489 			yield this.waitForTransaction(id).timeout(options.waitTimeout || 30000);
    490 		}
    491 		startedTransaction = true;
    492 		this._transactionID = id;
    493 		
    494 		Zotero.debug(`Beginning DB transaction ${id}`, 4);
    495 		
    496 		this._transactionPromise = new Zotero.Promise(function () {
    497 			resolve = arguments[0];
    498 		});
    499 		
    500 		// Set a timestamp for this transaction
    501 		this._transactionDate = new Date(Math.floor(new Date / 1000) * 1000);
    502 		
    503 		// Run begin callbacks
    504 		for (var i=0; i<this._callbacks.begin.length; i++) {
    505 			if (this._callbacks.begin[i]) {
    506 				this._callbacks.begin[i](id);
    507 			}
    508 		}
    509 		var conn = this._getConnection(options) || (yield this._getConnectionAsync(options));
    510 		var result = yield conn.executeTransaction(func);
    511 		Zotero.debug(`Committed DB transaction ${id}`, 4);
    512 		
    513 		// Clear transaction time
    514 		if (this._transactionDate) {
    515 			this._transactionDate = null;
    516 		}
    517 		
    518 		if (options.vacuumOnCommit) {
    519 			Zotero.debug('Vacuuming database');
    520 			yield this.queryAsync('VACUUM');
    521 			Zotero.debug('Done vacuuming');
    522 			
    523 		}
    524 		
    525 		this._transactionID = null;
    526 		
    527 		// Function to run once transaction has been committed but before any
    528 		// permanent callbacks
    529 		if (options.onCommit) {
    530 			this._callbacks.current.commit.push(options.onCommit);
    531 		}
    532 		this._callbacks.current.rollback = [];
    533 		
    534 		// Run temporary commit callbacks
    535 		var f;
    536 		while (f = this._callbacks.current.commit.shift()) {
    537 			yield Zotero.Promise.resolve(f(id));
    538 		}
    539 		
    540 		// Run commit callbacks
    541 		for (var i=0; i<this._callbacks.commit.length; i++) {
    542 			if (this._callbacks.commit[i]) {
    543 				yield this._callbacks.commit[i](id);
    544 			}
    545 		}
    546 		
    547 		return result;
    548 	}
    549 	catch (e) {
    550 		if (e.name == "TimeoutError") {
    551 			Zotero.debug(`Timed out waiting for transaction ${id}`, 1);
    552 		}
    553 		else {
    554 			Zotero.debug(`Rolled back DB transaction ${id}`, 1);
    555 			Zotero.debug(e.message, 1);
    556 		}
    557 		if (startedTransaction) {
    558 			this._transactionID = null;
    559 		}
    560 		
    561 		// Function to run once transaction has been committed but before any
    562 		// permanent callbacks
    563 		if (options.onRollback) {
    564 			this._callbacks.current.rollback.push(options.onRollback);
    565 		}
    566 		
    567 		// Run temporary commit callbacks
    568 		var f;
    569 		while (f = this._callbacks.current.rollback.shift()) {
    570 			yield Zotero.Promise.resolve(f(id));
    571 		}
    572 		
    573 		// Run rollback callbacks
    574 		for (var i=0; i<this._callbacks.rollback.length; i++) {
    575 			if (this._callbacks.rollback[i]) {
    576 				yield Zotero.Promise.resolve(this._callbacks.rollback[i](id));
    577 			}
    578 		}
    579 		
    580 		throw e;
    581 	}
    582 	finally {
    583 		// Reset options back to their previous values
    584 		if (options) {
    585 			for (let option in options) {
    586 				this[option] = origOptions[option];
    587 			}
    588 		}
    589 		
    590 		// Process all resolvers
    591 		if (resolve) {
    592 			resolve.call();
    593 		}
    594 	}
    595 });
    596 
    597 
    598 Zotero.DBConnection.prototype.inTransaction = function () {
    599 	return !!this._transactionID;
    600 }
    601 
    602 
    603 Zotero.DBConnection.prototype.waitForTransaction = function (id) {
    604 	if (!this._transactionID) {
    605 		return Zotero.Promise.resolve();
    606 	}
    607 	if (Zotero.Debug.enabled) {
    608 		Zotero.debug(`Waiting for DB transaction ${this._transactionID} to finish`
    609 			+ (id ? ` to start ${id}` : ""), 4);
    610 		Zotero.debug(Zotero.Debug.filterStack((new Error).stack), 5);
    611 	}
    612 	return this._transactionPromise;
    613 };
    614 
    615 
    616 Zotero.DBConnection.prototype.requireTransaction = function () {
    617 	if (!this._transactionID) {
    618 		throw new Error("Not in transaction");
    619 	}
    620 };
    621 
    622 
    623 /**
    624  * @param {String} sql SQL statement to run
    625  * @param {Array|String|Integer} [params] SQL parameters to bind
    626  * @return {Promise|Array} A promise for an array of rows. The individual
    627  *                         rows are Proxy objects that return values from the
    628  *                         underlying mozIStorageRows based on column names.
    629  */
    630 Zotero.DBConnection.prototype.queryAsync = Zotero.Promise.coroutine(function* (sql, params, options) {
    631 	try {
    632 		let onRow = null;
    633 		let conn = this._getConnection(options) || (yield this._getConnectionAsync(options));
    634 		[sql, params] = this.parseQueryAndParams(sql, params);
    635 		if (Zotero.Debug.enabled) {
    636 			this.logQuery(sql, params, options);
    637 		}
    638 		var failed = false;
    639 		if (options && options.onRow) {
    640 			// Errors in onRow don't stop the query unless StopIteration is thrown
    641 			onRow = function (row) {
    642 				try {
    643 					options.onRow(row);
    644 				}
    645 				catch (e) {
    646 					// If the onRow throws a StopIteration, stop gracefully
    647 					if (e instanceof StopIteration) {
    648 						Zotero.debug("Query cancelled", 3);
    649 					}
    650 					// Otherwise, mark the promise as rejected, which Sqlite.jsm doesn't do
    651 					// on a StopIteration by default
    652 					else {
    653 						failed = e;
    654 					}
    655 					throw StopIteration;
    656 				}
    657 			}
    658 		}
    659 		let rows;
    660 		if (options && options.noCache) {
    661 			rows = yield conn.execute(sql, params, onRow);
    662 		}
    663 		else {
    664 			rows = yield conn.executeCached(sql, params, onRow);
    665 		}
    666 		if (failed) {
    667 			throw failed;
    668 		}
    669 		// Parse out the SQL command being used
    670 		let op = sql.match(/^[^a-z]*[^ ]+/i);
    671 		if (op) {
    672 			op = op.toString().toLowerCase();
    673 		}
    674 		
    675 		// If SELECT statement, return result
    676 		if (op == 'select' || op == 'pragma') {
    677 			if (onRow) {
    678 				return;
    679 			}
    680 			// Fake an associative array with a proxy
    681 			let handler = {
    682 				get: function(target, name) {
    683 					// Ignore promise check
    684 					if (name == 'then') {
    685 						return undefined;
    686 					}
    687 					
    688 					try {
    689 						return target.getResultByName(name);
    690 					}
    691 					catch (e) {
    692 						Zotero.debug(e, 1);
    693 						var msg = "DB column '" + name + "' not found";
    694 						Zotero.debug(msg, 1);
    695 						throw new Error(msg);
    696 					}
    697 				},
    698 				has: function(target, name) {
    699 					try {
    700 						return !!target.getResultByName(name);
    701 					} catch (e) {
    702 						return false;
    703 					}
    704 				}
    705 			};
    706 			for (let i=0, len=rows.length; i<len; i++) {
    707 				rows[i] = new Proxy(rows[i], handler);
    708 			}
    709 			return rows;
    710 		}
    711 		else {
    712 			// lastInsertRowID is unreliable for async queries, so we don't bother
    713 			// returning it for SELECT and REPLACE queries
    714 			return;
    715 		}
    716 	}
    717 	catch (e) {
    718 		if (e.errors && e.errors[0]) {
    719 			var eStr = e + "";
    720 			eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e;
    721 			throw new Error(eStr + ' [QUERY: ' + sql + '] '
    722 				+ (params ? '[PARAMS: ' + params.join(', ') + '] ' : '')
    723 				+ '[ERROR: ' + e.errors[0].message + ']');
    724 		}
    725 		else {
    726 			throw e;
    727 		}
    728 	}
    729 });
    730 
    731 
    732 Zotero.DBConnection.prototype.queryTx = function (sql, params, options) {
    733 	return this.executeTransaction(function* () {
    734 		options = options || {};
    735 		delete options.tx;
    736 		return this.queryAsync(sql, params, options);
    737 	}.bind(this));
    738 };
    739 
    740 
    741 /**
    742  * @param {String} sql  SQL statement to run
    743  * @param {Array|String|Integer} [params]  SQL parameters to bind
    744  * @return {Promise<Array|Boolean>}  A promise for either the value or FALSE if no result
    745  */
    746 Zotero.DBConnection.prototype.valueQueryAsync = Zotero.Promise.coroutine(function* (sql, params, options = {}) {
    747 	try {
    748 		let conn = this._getConnection(options) || (yield this._getConnectionAsync(options));
    749 		[sql, params] = this.parseQueryAndParams(sql, params);
    750 		if (Zotero.Debug.enabled) {
    751 			this.logQuery(sql, params, options);
    752 		}
    753 		let rows = yield conn.executeCached(sql, params);
    754 		return rows.length ? rows[0].getResultByIndex(0) : false;
    755 	}
    756 	catch (e) {
    757 		if (e.errors && e.errors[0]) {
    758 			var eStr = e + "";
    759 			eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e;
    760 			throw new Error(eStr + ' [QUERY: ' + sql + '] '
    761 				+ (params ? '[PARAMS: ' + params.join(', ') + '] ' : '')
    762 				+ '[ERROR: ' + e.errors[0].message + ']');
    763 		}
    764 		else {
    765 			throw e;
    766 		}
    767 	}
    768 });
    769 
    770 
    771 /**
    772  * @param {String} sql SQL statement to run
    773  * @param {Array|String|Integer} [params] SQL parameters to bind
    774  * @return {Promise<Object>}  A promise for a proxied storage row
    775  */
    776 Zotero.DBConnection.prototype.rowQueryAsync = function (sql, params) {
    777 	return this.queryAsync(sql, params)
    778 	.then(function (rows) {
    779 		return rows.length ? rows[0] : false;
    780 	});
    781 };
    782 
    783 
    784 /**
    785  * @param {String} sql SQL statement to run
    786  * @param {Array|String|Integer} [params] SQL parameters to bind
    787  * @return {Promise<Array>}  A promise for an array of values in the column
    788  */
    789 Zotero.DBConnection.prototype.columnQueryAsync = Zotero.Promise.coroutine(function* (sql, params, options = {}) {
    790 	try {
    791 		let conn = this._getConnection(options) || (yield this._getConnectionAsync(options));
    792 		[sql, params] = this.parseQueryAndParams(sql, params);
    793 		if (Zotero.Debug.enabled) {
    794 			this.logQuery(sql, params, options);
    795 		}
    796 		let rows = yield conn.executeCached(sql, params);
    797 		var column = [];
    798 		for (let i=0, len=rows.length; i<len; i++) {
    799 			column.push(rows[i].getResultByIndex(0));
    800 		}
    801 		return column;
    802 	}
    803 	catch (e) {
    804 		if (e.errors && e.errors[0]) {
    805 			var eStr = e + "";
    806 			eStr = eStr.indexOf("Error: ") == 0 ? eStr.substr(7): e;
    807 			throw new Error(eStr + ' [QUERY: ' + sql + '] '
    808 				+ (params ? '[PARAMS: ' + params.join(', ') + '] ' : '')
    809 				+ '[ERROR: ' + e.errors[0].message + ']');
    810 		}
    811 		else {
    812 			throw e;
    813 		}
    814 	}
    815 });
    816 
    817 
    818 Zotero.DBConnection.prototype.logQuery = function (sql, params = [], options) {
    819 	if (options && options.debug === false) return;
    820 	var msg = sql;
    821 	if (params.length) {
    822 		msg += " [";
    823 		for (let i = 0; i < params.length; i++) {
    824 			let param = params[i];
    825 			let paramType = typeof param;
    826 			if (paramType == 'string') {
    827 				msg += "'" + param + "', ";
    828 			}
    829 			else {
    830 				msg += param + ", ";
    831 			}
    832 		}
    833 		msg = msg.substr(0, msg.length - 2) + "]";
    834 	}
    835 	Zotero.debug(msg, 4);
    836 }
    837 
    838 
    839 Zotero.DBConnection.prototype.tableExists = Zotero.Promise.coroutine(function* (table, db) {
    840 	yield this._getConnectionAsync();
    841 	var prefix = db ? db + '.' : '';
    842 	var sql = `SELECT COUNT(*) FROM ${prefix}sqlite_master WHERE type='table' AND tbl_name=?`;
    843 	var count = yield this.valueQueryAsync(sql, [table]);
    844 	return !!count;
    845 });
    846 
    847 
    848 /**
    849  * Parse SQL string and execute transaction with all statements
    850  *
    851  * @return {Promise}
    852  */
    853 Zotero.DBConnection.prototype.executeSQLFile = Zotero.Promise.coroutine(function* (sql) {
    854 	var nonCommentRE = /^[^-]/;
    855 	var trailingCommentRE = /^(.*?)(?:--.+)?$/;
    856 	
    857 	sql = sql.trim()
    858 		// Ugly hack to parse triggers with embedded semicolons
    859 		.replace(/;---/g, "TEMPSEMI")
    860 		.split("\n")
    861 		.filter(x => nonCommentRE.test(x))
    862 		.map(x => x.match(trailingCommentRE)[1])
    863 		.join("");
    864 	if (sql.substr(-1) == ";") {
    865 		sql = sql.substr(0, sql.length - 1);
    866 	}
    867 	
    868 	var statements = sql.split(";")
    869 		.map(x => x.replace(/TEMPSEMI/g, ";"));
    870 	
    871 	this.requireTransaction();
    872 	
    873 	var statement;
    874 	while (statement = statements.shift()) {
    875 		yield this.queryAsync(statement);
    876 	}
    877 });
    878 
    879 
    880 /*
    881  * Implements nsIObserver
    882  */
    883 Zotero.DBConnection.prototype.observe = function(subject, topic, data) {
    884 	switch (topic) {
    885 		case 'idle':
    886 			this.backupDatabase();
    887 			break;
    888 	}
    889 }
    890 
    891 
    892 // TEMP
    893 Zotero.DBConnection.prototype.vacuum = function () {
    894 	return this.executeTransaction(function* () {}, { vacuumOnCommit: true });
    895 };
    896 
    897 
    898 // TEMP
    899 Zotero.DBConnection.prototype.info = Zotero.Promise.coroutine(function* () {
    900 	var info = {};
    901 	var pragmas = ['auto_vacuum', 'cache_size', 'main.locking_mode', 'page_size'];
    902 	for (let p of pragmas) {
    903 		info[p] = yield Zotero.DB.valueQueryAsync(`PRAGMA ${p}`);
    904 	}
    905 	return info;
    906 });
    907 
    908 
    909 Zotero.DBConnection.prototype.integrityCheck = Zotero.Promise.coroutine(function* () {
    910 	var ok = yield this.valueQueryAsync("PRAGMA integrity_check");
    911 	return ok == 'ok';
    912 });
    913 
    914 
    915 Zotero.DBConnection.prototype.checkException = function (e) {
    916 	if (this._externalDB) {
    917 		return true;
    918 	}
    919 	
    920 	if (e.message.includes(this.DB_CORRUPTION_STRING)) {
    921 		// Write corrupt marker to data directory
    922 		var file = Zotero.File.pathToFile(this._dbPath + '.is.corrupt');
    923 		Zotero.File.putContents(file, '');
    924 		
    925 		this._dbIsCorrupt = true;
    926 		
    927 		var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
    928 								.getService(Components.interfaces.nsIPromptService);
    929 		
    930 		var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
    931 			+ (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING);
    932 		
    933 		var index = ps.confirmEx(null,
    934 			Zotero.getString('general.error'),
    935 			Zotero.getString('db.dbCorrupted', this._dbName) + '\n\n' + Zotero.getString('db.dbCorrupted.restart', Zotero.appName),
    936 			buttonFlags,
    937 			Zotero.getString('general.restartNow'),
    938 			Zotero.getString('general.restartLater'),
    939 			null, null, {});
    940 		
    941 		if (index == 0) {
    942 			var appStartup = Components.classes["@mozilla.org/toolkit/app-startup;1"]
    943 					.getService(Components.interfaces.nsIAppStartup);
    944 			appStartup.quit(Components.interfaces.nsIAppStartup.eRestart);
    945 			appStartup.quit(Components.interfaces.nsIAppStartup.eAttemptQuit);
    946 		}
    947 		
    948 		Zotero.skipLoading = true;
    949 		return false;
    950 	}
    951 	return true;
    952 }
    953 
    954 
    955 /**
    956  * Close the database
    957  * @param {Boolean} [permanent] If true, throw an error instead of
    958  *     allowing code to re-open the database again
    959  */
    960 Zotero.DBConnection.prototype.closeDatabase = Zotero.Promise.coroutine(function* (permanent) {
    961 	if (this._connection) {
    962 		Zotero.debug("Closing database");
    963 		this.closed = true;
    964 		yield this._connection.close();
    965 		this._connection = undefined;
    966 		this._connection = permanent ? false : null;
    967 		Zotero.debug("Database closed");
    968 	}
    969 });
    970 
    971 
    972 Zotero.DBConnection.prototype.backupDatabase = Zotero.Promise.coroutine(function* (suffix, force) {
    973 	if (this.skipBackup || this._externalDB || Zotero.skipLoading) {
    974 		this._debug("Skipping backup of database '" + this._dbName + "'", 1);
    975 		return false;
    976 	}
    977 	
    978 	var storageService = Components.classes["@mozilla.org/storage/service;1"]
    979 		.getService(Components.interfaces.mozIStorageService);
    980 	
    981 	if (!suffix) {
    982 		var numBackups = Zotero.Prefs.get("backup.numBackups");
    983 		if (numBackups < 1) {
    984 			return false;
    985 		}
    986 		if (numBackups > 24) {
    987 			numBackups = 24;
    988 		}
    989 	}
    990 	
    991 	if (Zotero.locked && !force) {
    992 		this._debug("Zotero is locked -- skipping backup of DB '" + this._dbName + "'", 2);
    993 		return false;
    994 	}
    995 	
    996 	if (this._backupPromise && this._backupPromise.isPending()) {
    997 		this._debug("Database " + this._dbName + " is already being backed up -- skipping", 2);
    998 		return false;
    999 	}
   1000 	
   1001 	// Start a promise that will be resolved when the backup is finished
   1002 	var resolveBackupPromise;
   1003 	if (this.inTransaction()) {
   1004 		yield this.waitForTransaction();
   1005 	}
   1006 	this._backupPromise = new Zotero.Promise(function () {
   1007 		resolveBackupPromise = arguments[0];
   1008 	});
   1009 	
   1010 	try {
   1011 		let corruptMarker = Zotero.File.pathToFile(this._dbPath + '.is.corrupt');
   1012 		
   1013 		if (this._dbIsCorrupt || corruptMarker.exists()) {
   1014 			this._debug("Database '" + this._dbName + "' is marked as corrupt -- skipping backup", 1);
   1015 			return false;
   1016 		}
   1017 		
   1018 		let file = this._dbPath;
   1019 		
   1020 		// For standard backup, make sure last backup is old enough to replace
   1021 		if (!suffix && !force) {
   1022 			let backupFile = this._dbPath + '.bak';
   1023 			if (yield OS.File.exists(backupFile)) {
   1024 				let currentDBTime = (yield OS.File.stat(file.path)).lastModificationDate;
   1025 				let lastBackupTime = (yield OS.File.stat(backupFile)).lastModificationDate;
   1026 				if (currentDBTime == lastBackupTime) {
   1027 					Zotero.debug("Database '" + this._dbName + "' hasn't changed -- skipping backup");
   1028 					return;
   1029 				}
   1030 				
   1031 				var now = new Date();
   1032 				var intervalMinutes = Zotero.Prefs.get('backup.interval');
   1033 				var interval = intervalMinutes * 60 *  1000;
   1034 				if ((now - lastBackupTime) < interval) {
   1035 					Zotero.debug("Last backup of database '" + this._dbName
   1036 						+ "' was less than " + intervalMinutes + " minutes ago -- skipping backup");
   1037 					return;
   1038 				}
   1039 			}
   1040 		}
   1041 		
   1042 		this._debug("Backing up database '" + this._dbName + "'");
   1043 		
   1044 		// Copy via a temporary file so we don't run into disk space issues
   1045 		// after deleting the old backup file
   1046 		var tmpFile = this._dbPath + '.tmp';
   1047 		if (yield OS.File.exists(tmpFile)) {
   1048 			try {
   1049 				yield OS.File.remove(tmpFile);
   1050 			}
   1051 			catch (e) {
   1052 				if (e.name == 'NS_ERROR_FILE_ACCESS_DENIED') {
   1053 					alert("Cannot delete " + OS.Path.basename(tmpFile));
   1054 				}
   1055 				throw (e);
   1056 			}
   1057 		}
   1058 		
   1059 		// Turn off DB locking before backup and reenable after, since otherwise
   1060 		// the lock is lost
   1061 		try {
   1062 			if (DB_LOCK_EXCLUSIVE) {
   1063 				yield this.queryAsync("PRAGMA main.locking_mode=NORMAL", false, { inBackup: true });
   1064 			}
   1065 			storageService.backupDatabaseFile(
   1066 				Zotero.File.pathToFile(file),
   1067 				OS.Path.basename(tmpFile),
   1068 				Zotero.File.pathToFile(file).parent
   1069 			);
   1070 		}
   1071 		catch (e) {
   1072 			Zotero.logError(e);
   1073 			return false;
   1074 		}
   1075 		finally {
   1076 			if (DB_LOCK_EXCLUSIVE) {
   1077 				yield this.queryAsync("PRAGMA main.locking_mode=EXCLUSIVE", false, { inBackup: true });
   1078 			}
   1079 		}
   1080 		
   1081 		// Open the backup to check for corruption
   1082 		try {
   1083 			var connection = storageService.openDatabase(Zotero.File.pathToFile(tmpFile));
   1084 		}
   1085 		catch (e) {
   1086 			Zotero.logError(e);
   1087 			this._debug("Database file '" + OS.Path.basename(tmpFile) + "' can't be opened -- skipping backup");
   1088 			if (yield OS.File.exists(tmpFile)) {
   1089 				yield OS.File.remove(tmpFile);
   1090 			}
   1091 			return false;
   1092 		}
   1093 		finally {
   1094 			if (connection) {
   1095 				let deferred = Zotero.Promise.defer();
   1096 				connection.asyncClose({
   1097 					complete: function () {
   1098 						deferred.resolve();
   1099 					}
   1100 				});
   1101 				yield deferred.promise;
   1102 			}
   1103         }
   1104 		
   1105 		// Special backup
   1106 		if (!suffix && numBackups > 1) {
   1107 			// Remove oldest backup file
   1108 			let targetFile = this._dbPath + '.' + (numBackups - 1) + '.bak';
   1109 			if (yield OS.File.exists(targetFile)) {
   1110 				yield OS.File.remove(targetFile);
   1111 			}
   1112 			
   1113 			// Shift old versions up
   1114 			for (var i=(numBackups - 1); i>=1; i--) {
   1115 				var targetNum = i;
   1116 				var sourceNum = targetNum - 1;
   1117 				
   1118 				let targetFile = this._dbPath + '.' + targetNum + '.bak';
   1119 				let sourceFile = this._dbPath + '.' + (sourceNum ? sourceNum + '.bak' : 'bak')
   1120 				
   1121 				if (!(yield OS.File.exists(sourceFile))) {
   1122 					continue;
   1123 				}
   1124 				
   1125 				Zotero.debug("Moving " + OS.Path.basename(sourceFile)
   1126 					+ " to " + OS.Path.basename(targetFile));
   1127 				yield OS.File.move(sourceFile, targetFile);
   1128 			}
   1129 		}
   1130 		
   1131 		let backupFile = this._dbPath + '.' + (suffix ? suffix + '.' : '') + 'bak';
   1132 		
   1133 		// Remove old backup file
   1134 		if (yield OS.File.exists(backupFile)) {
   1135 			OS.File.remove(backupFile);
   1136 		}
   1137 		
   1138 		yield OS.File.move(tmpFile, backupFile);
   1139 		Zotero.debug("Backed up to " + OS.Path.basename(backupFile));
   1140 		
   1141 		return true;
   1142 	}
   1143 	finally {
   1144 		resolveBackupPromise();
   1145 	}
   1146 });
   1147 
   1148 
   1149 /////////////////////////////////////////////////////////////////
   1150 //
   1151 // Private methods
   1152 //
   1153 /////////////////////////////////////////////////////////////////
   1154 
   1155 Zotero.DBConnection.prototype._getConnection = function (options) {
   1156 	if (this._backupPromise && this._backupPromise.isPending() && (!options || !options.inBackup)) {
   1157 		return false;
   1158 	}
   1159 	if (this._connection === false) {
   1160 		throw new Error("Database permanently closed; not re-opening");
   1161 	}
   1162 	return this._connection || false;
   1163 }
   1164 
   1165 /*
   1166  * Retrieve a link to the data store asynchronously
   1167  */
   1168 Zotero.DBConnection.prototype._getConnectionAsync = async function (options) {
   1169 	// If a backup is in progress, wait until it's done
   1170 	if (this._backupPromise && this._backupPromise.isPending() && (!options || !options.inBackup)) {
   1171 		Zotero.debug("Waiting for database backup to complete", 2);
   1172 		await this._backupPromise;
   1173 	}
   1174 	
   1175 	if (this._connection) {
   1176 		return this._connection;
   1177 	}
   1178 	else if (this._connection === false) {
   1179 		throw new Error("Database permanently closed; not re-opening");
   1180 	}
   1181 	
   1182 	this._debug("Asynchronously opening database '" + this._dbName + "'");
   1183 	Zotero.debug(this._dbPath);
   1184 	
   1185 	// Get the storage service
   1186 	var store = Components.classes["@mozilla.org/storage/service;1"].
   1187 		getService(Components.interfaces.mozIStorageService);
   1188 	
   1189 	var file = this._dbPath;
   1190 	var backupFile = this._dbPath + '.bak';
   1191 	var fileName = OS.Path.basename(file);
   1192 	var corruptMarker = this._dbPath + '.is.corrupt';
   1193 	
   1194 	catchBlock: try {
   1195 		if (await OS.File.exists(corruptMarker)) {
   1196 			throw new Error(this.DB_CORRUPTION_STRING);
   1197 		}
   1198 		this._connection = await Zotero.Promise.resolve(this.Sqlite.openConnection({
   1199 			path: file
   1200 		}));
   1201 	}
   1202 	catch (e) {
   1203 		// Don't deal with corrupted external dbs
   1204 		if (this._externalDB) {
   1205 			throw e;
   1206 		}
   1207 		
   1208 		Zotero.logError(e);
   1209 		
   1210 		if (e.message.includes(this.DB_CORRUPTION_STRING)) {
   1211 			this._debug(`Database file '${fileName}' corrupted`, 1);
   1212 			
   1213 			// No backup file! Eek!
   1214 			if (!await OS.File.exists(backupFile)) {
   1215 				this._debug("No backup file for DB '" + this._dbName + "' exists", 1);
   1216 				
   1217 				// Save damaged filed
   1218 				this._debug('Saving damaged DB file with .damaged extension', 1);
   1219 				let damagedFile = this._dbPath + '.damaged';
   1220 				Zotero.moveToUnique(file, damagedFile);
   1221 				
   1222 				// Create new main database
   1223 				this._connection = store.openDatabase(file);
   1224 				
   1225 				if (await OS.File.exists(corruptMarker)) {
   1226 					await OS.File.remove(corruptMarker);
   1227 				}
   1228 				
   1229 				Zotero.alert(
   1230 					null,
   1231 					Zotero.getString('startupError'),
   1232 					Zotero.getString('db.dbCorruptedNoBackup', fileName)
   1233 				);
   1234 				break catchBlock;
   1235 			}
   1236 			
   1237 			// Save damaged file
   1238 			this._debug('Saving damaged DB file with .damaged extension', 1);
   1239 			let damagedFile = this._dbPath + '.damaged';
   1240 			Zotero.moveToUnique(file, damagedFile);
   1241 			
   1242 			// Test the backup file
   1243 			try {
   1244 				Zotero.debug("Asynchronously opening DB connection");
   1245 				this._connection = await Zotero.Promise.resolve(this.Sqlite.openConnection({
   1246 					path: backupFile
   1247 				}));
   1248 			}
   1249 			// Can't open backup either
   1250 			catch (e) {
   1251 				// Create new main database
   1252 				this._connection = await Zotero.Promise.resolve(this.Sqlite.openConnection({
   1253 					path: file
   1254 				}));
   1255 				
   1256 				Zotero.alert(
   1257 					null,
   1258 					Zotero.getString('general.error'),
   1259 					Zotero.getString('db.dbRestoreFailed', fileName)
   1260 				);
   1261 				
   1262 				if (await OS.File.exists(corruptMarker)) {
   1263 					await OS.File.remove(corruptMarker);
   1264 				}
   1265 				
   1266 				break catchBlock;
   1267 			}
   1268 			
   1269 			this._connection = undefined;
   1270 			
   1271 			// Copy backup file to main DB file
   1272 			this._debug("Restoring database '" + this._dbName + "' from backup file", 1);
   1273 			try {
   1274 				await OS.File.copy(backupFile, file);
   1275 			}
   1276 			catch (e) {
   1277 				// TODO: deal with low disk space
   1278 				throw (e);
   1279 			}
   1280 			
   1281 			// Open restored database
   1282 			this._connection = await Zotero.Promise.resolve(this.Sqlite.openConnection({
   1283 				path: file
   1284 			}));
   1285 			this._debug('Database restored', 1);
   1286 			Zotero.alert(
   1287 				null,
   1288 				Zotero.getString('general.warning'),
   1289 				Zotero.getString('db.dbRestored', [
   1290 					fileName,
   1291 					Zotero.Date.getFileDateString(Zotero.File.pathToFile(backupFile)),
   1292 					Zotero.Date.getFileTimeString(Zotero.File.pathToFile(backupFile))
   1293 				])
   1294 			);
   1295 			
   1296 			if (await OS.File.exists(corruptMarker)) {
   1297 				await OS.File.remove(corruptMarker);
   1298 			}
   1299 			
   1300 			break catchBlock;
   1301 		}
   1302 		
   1303 		// Some other error that we don't yet know how to deal with
   1304 		throw (e);
   1305 	}
   1306 	
   1307 	if (!this._externalDB) {
   1308 		if (DB_LOCK_EXCLUSIVE) {
   1309 			await this.queryAsync("PRAGMA main.locking_mode=EXCLUSIVE");
   1310 		}
   1311 		else {
   1312 			await this.queryAsync("PRAGMA main.locking_mode=NORMAL");
   1313 		}
   1314 		
   1315 		// Set page cache size to 8MB
   1316 		let pageSize = await this.valueQueryAsync("PRAGMA page_size");
   1317 		let cacheSize = 8192000 / pageSize;
   1318 		await this.queryAsync("PRAGMA cache_size=" + cacheSize);
   1319 		
   1320 		// Enable foreign key checks
   1321 		await this.queryAsync("PRAGMA foreign_keys=true");
   1322 		
   1323 		// Register idle observer for DB backup
   1324 		Zotero.Schema.schemaUpdatePromise.then(() => {
   1325 			Zotero.debug("Initializing DB backup idle observer");
   1326 			var idleService = Components.classes["@mozilla.org/widget/idleservice;1"]
   1327 				.getService(Components.interfaces.nsIIdleService);
   1328 			idleService.addIdleObserver(this, 300);
   1329 		});
   1330 	}
   1331 	
   1332 	return this._connection;
   1333 };
   1334 
   1335 
   1336 Zotero.DBConnection.prototype._debug = function (str, level) {
   1337 	var prefix = this._dbName == 'zotero' ? '' : '[' + this._dbName + '] ';
   1338 	Zotero.debug(prefix + str, level);
   1339 }