zotero/test/tests/dbTest.js
Dan Stillman 74a3617e7e
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled
Fix full-text indexing failing silently after periodic vacuum or backup
vacuum() and the APFS-cloning offline backup path both close and
reopen the SQLite connection, which drops all ATTACHed databases --
including the in-memory "indexing" alias used for the fulltextWords
scratch table set up in Fulltext.init(). Once the connection is
reopened, indexing queries fail with "no such table:
indexing.fulltextWords", and indexItems()'s ignoreErrors path
routes the error to logError(), so indexing silently stops working
for the rest of the session.

Add an onConnect() hook on Zotero.DBConnection for per-connection
state that doesn't persist across reopens, and use it from
Fulltext.init() to re-attach the indexing DB on each reconnect.

Regression sources:

- 67288047f3 ("Use APFS cloning for file copies on macOS") flips
  online idle backups to offline-with-clone on APFS, so every idle
  backup interval (24h default) closes and reopens the connection.
  Affects Mac users on APFS.
- b27c4cb023 ("Enable SQLite WAL mode and add periodic VACUUM
  INTO") adds the vacuum path, which closes and reopens on the
  first idle period in a session that passes the freelist/time
  gates. Affects all users with sufficient DB churn, roughly once
  per 14 days.

https://forums.zotero.org/discussion/131576/debug-id-d848621212-indexing-of-pdfs-fail-zotero-9-0-3
https://forums.zotero.org/discussion/131718/possible-bug-regression-report-search-unusable-on-macos-zotero-9-0-3-with-large-library
(cherry picked from commit 70c4cadab1)
2026-05-22 11:00:03 -04:00

468 lines
16 KiB
JavaScript

describe("Zotero.DB", function () {
var tmpTable = "tmpDBTest";
before(function* () {
this.timeout(5000);
Zotero.debug("Waiting for DB activity to settle");
yield Zotero.DB.waitForTransaction();
yield Zotero.Promise.delay(1000);
});
beforeEach(function* () {
yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable);
yield Zotero.DB.queryAsync("CREATE TABLE " + tmpTable + " (foo INT)");
});
after(function* () {
yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable);
});
describe("#queryAsync()", function () {
var tmpTable;
before(function* () {
tmpTable = "tmp_queryAsync";
yield Zotero.DB.queryAsync("CREATE TEMPORARY TABLE " + tmpTable + " (a, b)");
yield Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (1, 2)");
yield Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (3, 4)");
yield Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (5, NULL)");
})
after(function* () {
if (tmpTable) {
yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable);
}
})
it("should throw an error if no parameters are passed for a query with placeholders", async function () {
var e = await getPromiseError(Zotero.DB.queryAsync("SELECT itemID FROM items WHERE itemID=?"));
assert.ok(e);
assert.include(e.message, "for query containing placeholders");
})
it("should throw an error if too few parameters are passed", async function () {
var e = await getPromiseError(Zotero.DB.queryAsync("SELECT itemID FROM items WHERE itemID=? OR itemID=?", [1]));
assert.ok(e);
assert.include(e.message, "Incorrect number of parameters provided for query");
})
it("should throw an error if too many parameters are passed", async function () {
var e = await getPromiseError(Zotero.DB.queryAsync("SELECT itemID FROM items WHERE itemID=?", [1, 2]));
assert.ok(e);
assert.include(e.message, "Incorrect number of parameters provided for query");
})
it("should throw an error if too many parameters are passed for numbered placeholders", async function () {
var e = await getPromiseError(Zotero.DB.queryAsync("SELECT itemID FROM items WHERE itemID=?1 OR itemID=?1", [1, 2]));
assert.ok(e);
assert.include(e.message, "Incorrect number of parameters provided for query");
})
it("should accept a single placeholder given as a value", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b=?", 2);
assert.lengthOf(rows, 1);
assert.equal(rows[0].a, 1);
})
it("should accept a single placeholder given as an array", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b=?", [2]);
assert.lengthOf(rows, 1);
assert.equal(rows[0].a, 1);
})
it("should accept multiple placeholders", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b=? OR b=?", [2, 4]);
assert.lengthOf(rows, 2);
assert.equal(rows[0].a, 1);
assert.equal(rows[1].a, 3);
});
it("should accept combination of numbered and unnumbered placeholders", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE (a=?1 OR b=?1) OR b=?", [2, 4]);
assert.lengthOf(rows, 2);
assert.equal(rows[0].a, 1);
assert.equal(rows[1].a, 3);
});
it("should accept a single placeholder within parentheses", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b IN (?)", 2);
assert.lengthOf(rows, 1);
assert.equal(rows[0].a, 1);
})
it("should accept multiple placeholders within parentheses", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b IN (?, ?)", [2, 4]);
assert.lengthOf(rows, 2);
assert.equal(rows[0].a, 1);
assert.equal(rows[1].a, 3);
})
it("should replace =? with IS NULL if NULL is passed as a value", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b=?", null);
assert.lengthOf(rows, 1);
assert.equal(rows[0].a, 5);
})
it("should replace =? with IS NULL if NULL is passed in an array", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b=?", [null]);
assert.lengthOf(rows, 1);
assert.equal(rows[0].a, 5);
})
it("should replace ? with NULL for placeholders within parentheses in INSERT statements", async function () {
await Zotero.DB.queryAsync("CREATE TEMPORARY TABLE tmp_srqwnfpwpinss (a, b)");
// Replace ", ?"
await Zotero.DB.queryAsync("INSERT INTO tmp_srqwnfpwpinss (a, b) VALUES (?, ?)", [1, null]);
assert.equal(
((await Zotero.DB.valueQueryAsync("SELECT a FROM tmp_srqwnfpwpinss WHERE b IS NULL"))),
1
);
// Replace "(?"
await Zotero.DB.queryAsync("DELETE FROM tmp_srqwnfpwpinss");
await Zotero.DB.queryAsync("INSERT INTO tmp_srqwnfpwpinss (a, b) VALUES (?, ?)", [null, 2]);
assert.equal(
((await Zotero.DB.valueQueryAsync("SELECT b FROM tmp_srqwnfpwpinss WHERE a IS NULL"))),
2
);
await Zotero.DB.queryAsync("DROP TABLE tmp_srqwnfpwpinss");
})
it("should throw an error if NULL is passed for placeholder within parentheses in a SELECT statement", async function () {
var e = await getPromiseError(Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b IN (?)", null));
assert.ok(e);
assert.include(e.message, "NULL cannot be used for parenthesized placeholders in SELECT queries");
})
it("should handle numbered parameters", async function () {
var rows = await Zotero.DB.queryAsync("SELECT a FROM " + tmpTable + " WHERE b=?1 "
+ "UNION SELECT b FROM " + tmpTable + " WHERE b=?1", 2);
assert.lengthOf(rows, 2);
assert.equal(rows[0].a, 1);
assert.equal(rows[1].a, 2);
})
it("should throw an error if onRow throws an error", async function () {
var i = 0;
var e = Zotero.DB.queryAsync(
"SELECT * FROM " + tmpTable,
false,
{
onRow: function (row) {
if (i > 0) {
throw new Error("Failed");
}
i++;
}
}
);
e = await getPromiseError(e)
assert.ok(e);
assert.equal(e.message, "Failed");
});
it("should stop gracefully if onRow calls cancel()", async function () {
var i = 0;
var rows = [];
await Zotero.DB.queryAsync(
"SELECT * FROM " + tmpTable,
false,
{
onRow: function (row, cancel) {
if (i > 0) {
cancel();
return;
}
rows.push(row.getResultByIndex(0));
i++;
}
}
);
assert.lengthOf(rows, 1);
});
})
describe("#executeTransaction()", function () {
it("should serialize concurrent transactions", async function () {
var resolve1, resolve2, reject1, reject2;
var promise1 = new Promise(function (resolve, reject) {
resolve1 = resolve;
reject1 = reject;
});
var promise2 = new Promise(function (resolve, reject) {
resolve2 = resolve;
reject2 = reject;
});
Zotero.DB.executeTransaction(async function () {
await Zotero.Promise.delay(250);
var num = await Zotero.DB.valueQueryAsync("SELECT COUNT(*) FROM " + tmpTable);
assert.equal(num, 0);
await Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (1)");
assert.ok(Zotero.DB.inTransaction());
})
.then(resolve1)
.catch(reject1);
Zotero.DB.executeTransaction(async function () {
var num = await Zotero.DB.valueQueryAsync("SELECT COUNT(*) FROM " + tmpTable);
assert.equal(num, 1);
await Zotero.Promise.delay(500);
await Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (2)");
assert.ok(Zotero.DB.inTransaction());
})
.then(resolve2)
.catch(reject2);
await Promise.all([promise1, promise2]);
});
it("should serialize queued transactions", async function () {
var resolve1, resolve2, reject1, reject2, resolve3, reject3;
var promise1 = new Promise(function (resolve, reject) {
resolve1 = resolve;
reject1 = reject;
});
var promise2 = new Promise(function (resolve, reject) {
resolve2 = resolve;
reject2 = reject;
});
var promise3 = new Promise(function (resolve, reject) {
resolve3 = resolve;
reject3 = reject;
});
// Start a transaction and have it delay
Zotero.DB.executeTransaction(async function () {
await Zotero.Promise.delay(100);
var num = await Zotero.DB.valueQueryAsync("SELECT COUNT(*) FROM " + tmpTable);
assert.equal(num, 0);
await Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (1)");
assert.ok(Zotero.DB.inTransaction());
})
.then(resolve1)
.catch(reject1);
// Start two more transactions, which should wait on the first
Zotero.DB.executeTransaction(async function () {
var num = await Zotero.DB.valueQueryAsync("SELECT COUNT(*) FROM " + tmpTable);
assert.equal(num, 1);
await Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (2)");
assert.ok(Zotero.DB.inTransaction());
})
.then(resolve2)
.catch(reject2);
Zotero.DB.executeTransaction(async function () {
var num = await Zotero.DB.valueQueryAsync("SELECT COUNT(*) FROM " + tmpTable);
assert.equal(num, 2);
await Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (3)");
// But make sure the second queued transaction doesn't start at the same time,
// such that the first queued transaction gets closed while the second is still
// running
assert.ok(Zotero.DB.inTransaction());
})
.then(resolve3)
.catch(reject3);
await Promise.all([promise1, promise2, promise3]);
})
it("should roll back on error", async function () {
await Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (1)");
try {
await Zotero.DB.executeTransaction(async function () {
await Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (2)");
throw 'Aborting transaction -- ignore';
});
}
catch (e) {
if (typeof e != 'string' || !e.startsWith('Aborting transaction')) throw e;
}
var count = await Zotero.DB.valueQueryAsync("SELECT COUNT(*) FROM " + tmpTable + "");
assert.equal(count, 1);
var conn = await Zotero.DB._getConnectionAsync();
assert.isFalse(conn.transactionInProgress);
await Zotero.DB.queryAsync("DROP TABLE " + tmpTable);
});
it("should run onRollback callbacks", async function () {
var callbackRan = false;
try {
await Zotero.DB.executeTransaction(
async function () {
await Zotero.DB.queryAsync("INSERT INTO " + tmpTable + " VALUES (1)");
throw 'Aborting transaction -- ignore';
},
{
onRollback: function () {
callbackRan = true;
}
}
);
}
catch (e) {
if (typeof e != 'string' || !e.startsWith('Aborting transaction')) throw e;
}
assert.ok(callbackRan);
await Zotero.DB.queryAsync("DROP TABLE " + tmpTable);
});
it("should time out on nested transactions", async function () {
var e;
await Zotero.DB.executeTransaction(async function () {
e = await getPromiseError(
Promise.race([
Zotero.Promise.delay(250).then(() => {
var e = new Error;
e.name = "TimeoutError";
throw e;
}),
Zotero.DB.executeTransaction(async function () {})
])
);
});
assert.ok(e);
assert.equal(e.name, "TimeoutError");
});
it("should run onRollback callbacks for timed-out nested transactions", async function () {
var callback1Ran = false;
var callback2Ran = false;
try {
await Zotero.DB.executeTransaction(async function () {
await Zotero.DB.executeTransaction(
async function () {},
{
waitTimeout: 100,
onRollback: function () {
callback1Ran = true;
}
}
)
},
{
onRollback: function () {
callback2Ran = true;
}
});
}
catch (e) {
if (e.name != "TimeoutError") throw e;
}
assert.ok(callback1Ran);
assert.ok(callback2Ran);
});
})
describe("#columnExists()", function () {
it("should return true if a column exists", async function () {
assert.isTrue(await Zotero.DB.columnExists('items', 'itemID'));
});
it("should return false if a column doesn't exists", async function () {
assert.isFalse(await Zotero.DB.columnExists('items', 'foo'));
});
it("should return false if a table doesn't exists", async function () {
assert.isFalse(await Zotero.DB.columnExists('foo', 'itemID'));
});
});
describe("#indexExists()", function () {
it("should return true if an index exists", async function () {
assert.isTrue(await Zotero.DB.indexExists('items_synced'));
});
it("should return false if an index doesn't exists", async function () {
assert.isFalse(await Zotero.DB.indexExists('foo'));
});
});
describe("#parseSQLFile", function () {
it("should extract tables and indexes from userdata SQL file", async function () {
var sql = Zotero.File.getResource(`resource://zotero/schema/userdata.sql`);
var statements = await Zotero.DB.parseSQLFile(sql);
assert.isTrue(statements.some(x => x.startsWith('CREATE TABLE items')));
});
});
describe("#backUpDatabase()", function () {
var bakFile;
var bakFile2;
beforeEach(async function () {
bakFile = Zotero.DB.path + '.test.bak';
bakFile2 = Zotero.DB.path + '.test2.bak';
await IOUtils.remove(bakFile);
await IOUtils.remove(bakFile2);
});
afterEach(async function () {
await IOUtils.remove(bakFile);
await IOUtils.remove(bakFile2);
});
it("should perform an offline backup", async function () {
await Zotero.DB.backUpDatabase({ suffix: 'test' });
assert.isTrue(await IOUtils.exists(bakFile));
assert.equal(await Zotero.DB.valueQueryAsync("PRAGMA main.locking_mode"), "exclusive");
});
it("should perform an online backup", async function () {
await Zotero.DB.backUpDatabase({ suffix: 'test', online: true });
assert.isTrue(await IOUtils.exists(bakFile));
assert.equal(await Zotero.DB.valueQueryAsync("PRAGMA main.locking_mode"), "exclusive");
});
it("shouldn't perform an offline backup if one is already in progress", async function () {
var promise = Zotero.DB.backUpDatabase({ suffix: 'test' });
var result2 = await Zotero.DB.backUpDatabase({ suffix: 'test2' });
var result1 = await promise;
assert.isTrue(result1);
assert.isTrue(await IOUtils.exists(bakFile));
// Return value is true, but file won't exist
assert.isTrue(result2);
assert.isFalse(await IOUtils.exists(bakFile2));
});
it("should perform an offline backup if an online backup is already in progress", async function () {
// On APFS, online backups use cloning (offline path), so this doesn't apply
if (Zotero.File.isAPFS(Zotero.DB.path)) this.skip();
var promise = Zotero.DB.backUpDatabase({ suffix: 'test', online: true });
var result = await Zotero.DB.backUpDatabase({ suffix: 'test2' });
// The online backup fails
assert.ok(await getPromiseError(promise));
assert.isFalse(await IOUtils.exists(bakFile));
assert.isTrue(result);
assert.isTrue(await IOUtils.exists(bakFile2));
});
it("shouldn't perform an online backup if one is already in progress", async function () {
// On APFS, online backups use cloning (offline path), so this doesn't apply
if (Zotero.File.isAPFS(Zotero.DB.path)) this.skip();
var promise = Zotero.DB.backUpDatabase({ suffix: 'test', online: true });
var result = await Zotero.DB.backUpDatabase({ suffix: 'test2', online: true });
await promise;
assert.isFalse(result);
assert.isFalse(await IOUtils.exists(bakFile2));
assert.isTrue(await IOUtils.exists(bakFile));
});
});
describe("#onConnect()", function () {
it("should run registered callbacks after the connection is reopened", async function () {
let count = 0;
Zotero.DB.onConnect(async () => {
count++;
});
await Zotero.DB.closeDatabase();
await Zotero.DB.valueQueryAsync("SELECT 1");
assert.equal(count, 1);
});
});
});