Fix database access on network filesystems on macOS and Linux

On macOS, SQLite chooses locking methods based on the filesystem
containing the database, and network filesystems (e.g., SMB, NFS),
read-only volumes, and filesystems without byte-range locking get
methods without shared-memory support, which WAL requires. Opening a
database with an adjacent WAL file on those crashes -- Mozilla's VFS
wrapper hides the missing shared-memory methods from SQLite's WAL
support check -- so the first Zotero 10 run converted the database to
WAL and every launch after that segfaulted during connection
initialization.

On macOS, mirror SQLite's method selection and use a rollback journal
when shared memory isn't available, converting an existing WAL database
before opening it: an empty WAL by reverting the header format versions
in place, and a non-empty WAL by replaying it into a temporary copy on
local disk that replaces the database file only after passing an
integrity check. A WAL file next to an already-converted database
(e.g., from an interrupted conversion) goes through the same
conversion. Also use openNotExclusive during integrity checks and
corruption recovery, which otherwise fail on SMB shares with an I/O
error from the exclusive open lock.

This corrects 22055d92b7, which passed openNotExclusive on all
platforms for an open failure described as affecting macOS and Linux,
and expected locking_mode=EXCLUSIVE to keep the WAL index in heap
memory with no -shm file. Neither claim held up: mozStorage opens the
WAL while initializing the connection, before any pragma can run, so
the index uses shared memory unless the exclusive VFS is in use, and
the exclusive open works on Linux CIFS mounts -- where non-exclusive
access instead made SQLite's lock-upgrade sequence trip over the SMB
byte-range lock mapping, leaving startup hung and the database never
created. So pass openNotExclusive only on macOS. On Linux this restores
unix-excl, which performs all locking under a single held lock and
keeps the WAL index in heap memory; Windows has no distinct exclusive
VFS and is unaffected.

https://forums.zotero.org/discussion/133258/
This commit is contained in:
Dan Stillman 2026-08-18 10:13:33 -04:00
parent 5757396197
commit bba85a3939
3 changed files with 518 additions and 38 deletions

View file

@ -1535,13 +1535,14 @@ Zotero.DBConnection.prototype._getConnectionAsync = async function () {
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 {
@ -1552,20 +1553,32 @@ Zotero.DBConnection.prototype._getConnectionAsync = async function () {
throw e;
}
}
useWAL = await this._canUseWAL(file);
}
try {
if (await OS.File.exists(corruptMarker)) {
throw new Error(this.DB_CORRUPTION_STRINGS[0]);
}
// 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) and keeps the WAL index in heap
// memory, avoiding the -shm file that can't be created on a network filesystem. See #4860.
// 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: true
openNotExclusive: Zotero.isMac
}));
}
catch (e) {
@ -1596,13 +1609,13 @@ Zotero.DBConnection.prototype._getConnectionAsync = async function () {
await this.queryAsync("PRAGMA main.locking_mode=NORMAL");
}
// Enable WAL mode for better write performance. With locking_mode=EXCLUSIVE
// set first, SQLite uses heap memory for the WAL index instead of shared
// memory, so no -shm file is created on disk.
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");
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");
@ -1677,6 +1690,190 @@ Zotero.DBConnection.prototype._getConnectionAsync = async function () {
};
/**
* 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]
@ -1987,7 +2184,7 @@ Zotero.DBConnection.prototype._journalFilesExist = async function () {
* @return {Boolean}
*/
Zotero.DBConnection.prototype._integrityCheckFile = async function (path, quick) {
var connection = await this.Sqlite.openConnection({ path });
var connection = await this.Sqlite.openConnection({ path, openNotExclusive: Zotero.isMac });
var ok;
try {
try {
@ -2033,7 +2230,7 @@ Zotero.DBConnection.prototype._reindexToCopy = async function (path) {
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 });
let connection = await this.Sqlite.openConnection({ path: tmpFile, openNotExclusive: Zotero.isMac });
try {
try {
await connection.execute("REINDEX");
@ -2199,7 +2396,8 @@ Zotero.DBConnection.prototype._handleCorruptionMarker = async function () {
// keep it and skip the backup restore
if (await this._recoverFromStaleJournalFiles()) {
this._connection = await Promise.resolve(this.Sqlite.openConnection({
path: file
path: file,
openNotExclusive: Zotero.isMac
}));
this._debug('Database recovered from stale journal files', 1);
if (await OS.File.exists(corruptMarker)) {
@ -2234,7 +2432,8 @@ Zotero.DBConnection.prototype._handleCorruptionMarker = async function () {
// Create new main database
this._connection = await Promise.resolve(this.Sqlite.openConnection({
path: file
path: file,
openNotExclusive: Zotero.isMac
}));
if (await OS.File.exists(corruptMarker)) {
@ -2299,7 +2498,8 @@ Zotero.DBConnection.prototype._handleCorruptionMarker = async function () {
// Create new main database
this._connection = await Promise.resolve(this.Sqlite.openConnection({
path: file
path: file,
openNotExclusive: Zotero.isMac
}));
Zotero.alert(
@ -2345,7 +2545,8 @@ Zotero.DBConnection.prototype._handleCorruptionMarker = async function () {
// Open restored database
this._connection = await Promise.resolve(this.Sqlite.openConnection({
path: file
path: file,
openNotExclusive: Zotero.isMac
}));
this._debug('Database restored', 1);
let backupDate = '';

View file

@ -1109,32 +1109,39 @@ Zotero.File = new function () {
};
var _isAPFSCache = {};
var _fsInfoCache = {};
/**
* Check if a path is on an APFS volume
* Get information about the filesystem containing a path (macOS only)
*
* statfs() is called on the path itself, following symlinks, so a symlinked file is
* classified by its target's volume. The parent directory is used if the path doesn't
* exist.
*
* @param {String} path
* @return {Boolean}
* @return {Object|null} - { fsType: statfs f_fstypename (e.g., 'apfs', 'smbfs'),
* readOnly: Boolean }, or null on other platforms or if the check fails
*/
this.isAPFS = function (path) {
if (!Zotero.isMac) return false;
this.getFileSystemInfo = function (path) {
if (!Zotero.isMac) return null;
let dir = PathUtils.parent(path);
if (dir in _isAPFSCache) {
return _isAPFSCache[dir];
if (path in _fsInfoCache) {
return _fsInfoCache[path];
}
let result = false;
let result = null;
try {
let { ctypes } = ChromeUtils.importESModule(
"resource://gre/modules/ctypes.sys.mjs"
);
// struct statfs -- f_fstypename is a char[16] at byte offset 72
// struct statfs -- f_flags is a uint32 at byte offset 64, f_fstypename is a
// char[16] at byte offset 72
const STATFS_SIZE = 2168;
const FLAGS_OFFSET = 64;
const FSTYPENAME_OFFSET = 72;
const FSTYPENAME_LEN = 16;
let buf = new ctypes.ArrayType(ctypes.uint8_t, STATFS_SIZE)();
const MNT_RDONLY = 0x1;
let buf = new (ctypes.ArrayType(ctypes.uint8_t, STATFS_SIZE))();
let lib = ctypes.open("/usr/lib/libSystem.B.dylib");
try {
let statfs = lib.declare(
@ -1144,14 +1151,22 @@ Zotero.File = new function () {
ctypes.char.ptr,
ctypes.voidptr_t
);
if (statfs(dir, buf.address()) === 0) {
let typeName = '';
for (let i = FSTYPENAME_OFFSET; i < FSTYPENAME_OFFSET + FSTYPENAME_LEN; i++) {
if (buf[i] === 0) break;
typeName += String.fromCharCode(buf[i]);
if (statfs(path, buf.address()) !== 0) {
if (statfs(PathUtils.parent(path), buf.address()) !== 0) {
throw new Error("statfs() failed");
}
result = typeName === 'apfs';
}
let typeName = '';
for (let i = FSTYPENAME_OFFSET; i < FSTYPENAME_OFFSET + FSTYPENAME_LEN; i++) {
if (buf[i] === 0) break;
typeName += String.fromCharCode(buf[i]);
}
let flags = buf[FLAGS_OFFSET] | (buf[FLAGS_OFFSET + 1] << 8)
| (buf[FLAGS_OFFSET + 2] << 16) | (buf[FLAGS_OFFSET + 3] << 24);
result = {
fsType: typeName,
readOnly: !!(flags & MNT_RDONLY)
};
}
finally {
lib.close();
@ -1161,7 +1176,105 @@ Zotero.File = new function () {
Zotero.warn("Failed to check filesystem type: " + e);
}
_isAPFSCache[dir] = result;
_fsInfoCache[path] = result;
return result;
};
/**
* Check if a path is on an APFS volume
*
* @param {String} path
* @return {Boolean}
*/
this.isAPFS = function (path) {
return this.getFileSystemInfo(path)?.fsType === 'apfs';
};
/**
* Check whether the filesystem containing a path supports POSIX byte-range locks
* (macOS only)
*
* Performs the same fcntl(F_GETLK) probe SQLite uses to choose its locking methods.
* If the path doesn't exist, a temporary sibling file is probed instead.
*
* @param {String} path
* @return {Promise<Boolean>}
*/
this.supportsByteRangeLocks = async function (path) {
if (!Zotero.isMac) return true;
let probePath = path;
let probeCreated = false;
if (!(await IOUtils.exists(path))) {
probePath = path + '.' + Zotero.Utilities.randomString() + '.lock-probe';
// Exclusive creation, so that an existing file is never overwritten and deleted
await IOUtils.write(probePath, new Uint8Array(0), { mode: 'create' });
probeCreated = true;
}
let result = false;
try {
let { ctypes } = ChromeUtils.importESModule(
"resource://gre/modules/ctypes.sys.mjs"
);
let lib = ctypes.open("/usr/lib/libSystem.B.dylib");
try {
// open() and fcntl() are variadic, which matters for argument passing on ARM64
let open = lib.declare(
"open", ctypes.default_abi, ctypes.int, ctypes.char.ptr, ctypes.int, "..."
);
let close = lib.declare(
"close", ctypes.default_abi, ctypes.int, ctypes.int
);
let fcntl = lib.declare(
"fcntl", ctypes.default_abi, ctypes.int, ctypes.int, ctypes.int, "..."
);
let flockType = ctypes.StructType("flock", [
{ l_start: ctypes.int64_t },
{ l_len: ctypes.int64_t },
{ l_pid: ctypes.int32_t },
{ l_type: ctypes.int16_t },
{ l_whence: ctypes.int16_t }
]);
const O_RDONLY = 0x0;
const F_GETLK = 7;
const F_RDLCK = 1;
const SEEK_SET = 0;
let fd = open(probePath, O_RDONLY);
if (fd >= 0) {
try {
let lock = new flockType();
lock.l_start = 0;
lock.l_len = 1;
lock.l_pid = 0;
lock.l_type = F_RDLCK;
lock.l_whence = SEEK_SET;
result = fcntl(fd, F_GETLK, lock.address()) != -1;
}
finally {
close(fd);
}
}
}
finally {
lib.close();
}
}
catch (e) {
Zotero.warn("Failed to check byte-range lock support: " + e);
}
finally {
if (probeCreated) {
try {
await IOUtils.remove(probePath, { ignoreAbsent: true });
}
catch (e) {
Zotero.logError(e);
}
}
}
return result;
};

View file

@ -1098,6 +1098,166 @@ describe("Zotero.DB", function () {
});
describe("#_downgradeDatabaseFromWAL()", function () {
it("should convert a cleanly closed WAL database to a rollback journal", async function () {
let dir = await getTempDirectory();
let dbPath = PathUtils.join(dir, 'test.sqlite');
let db = new Zotero.DBConnection(dbPath);
await db.queryAsync("PRAGMA journal_mode=WAL");
await db.queryAsync("CREATE TABLE foo (a INT)");
await db.queryAsync("INSERT INTO foo VALUES (1)");
await db.closeDatabase();
assert.isTrue(await db._downgradeDatabaseFromWAL(dbPath));
let header = await IOUtils.read(dbPath, { maxBytes: 20 });
assert.equal(header[18], 1);
assert.isFalse(await IOUtils.exists(dbPath + '-wal'));
try {
assert.equal(await db.valueQueryAsync("SELECT COUNT(*) FROM foo"), 1);
assert.equal(await db.valueQueryAsync("PRAGMA main.journal_mode"), 'delete');
}
finally {
await db.closeDatabase();
}
});
it("should replay a non-empty WAL", async function () {
let dir = await getTempDirectory();
let dbPath = PathUtils.join(dir, 'test.sqlite');
let db = new Zotero.DBConnection(dbPath);
await db.queryAsync("PRAGMA journal_mode=WAL");
await db.queryAsync("CREATE TABLE foo (a INT)");
await db.queryAsync("INSERT INTO foo VALUES (1)");
// Copy the files before closing, so that the copied WAL contains uncheckpointed data
await IOUtils.copy(dbPath, dbPath + '.copy');
await IOUtils.copy(dbPath + '-wal', dbPath + '.copy-wal');
await db.closeDatabase(true);
await IOUtils.move(dbPath + '.copy', dbPath);
await IOUtils.move(dbPath + '.copy-wal', dbPath + '-wal');
assert.isAbove((await IOUtils.stat(dbPath + '-wal')).size, 0);
let db2 = new Zotero.DBConnection(dbPath);
assert.isTrue(await db2._downgradeDatabaseFromWAL(dbPath));
try {
assert.equal(await db2.valueQueryAsync("SELECT COUNT(*) FROM foo"), 1);
}
finally {
await db2.closeDatabase();
}
});
it("should preserve the original files if the converted database fails validation", async function () {
let dir = await getTempDirectory();
let dbPath = PathUtils.join(dir, 'test.sqlite');
let db = new Zotero.DBConnection(dbPath);
await db.queryAsync("PRAGMA journal_mode=WAL");
await db.queryAsync("CREATE TABLE foo (a TEXT)");
for (let i = 0; i < 100; i++) {
await db.queryAsync("INSERT INTO foo VALUES (?)", "x".repeat(4000));
}
await db.closeDatabase(true);
// Reopen and make a small change, so that the WAL contains only the pages it
// touched, and capture the files before the WAL is checkpointed at close
let db2 = new Zotero.DBConnection(dbPath);
await db2.queryAsync("PRAGMA journal_mode=WAL");
await db2.queryAsync("INSERT INTO foo VALUES ('y')");
await IOUtils.copy(dbPath, dbPath + '.copy');
await IOUtils.copy(dbPath + '-wal', dbPath + '.copy-wal');
await db2.closeDatabase(true);
await IOUtils.move(dbPath + '.copy', dbPath);
await IOUtils.move(dbPath + '.copy-wal', dbPath + '-wal');
// Zero out a page in the middle of the database file that the WAL doesn't
// contain, so that both the converted copy and the database file alone fail
// their integrity checks
let bytes = await IOUtils.read(dbPath);
bytes.fill(0, 65536, 69632);
await IOUtils.write(dbPath, bytes);
let origSize = bytes.length;
let db3 = new Zotero.DBConnection(dbPath);
let e = null;
try {
await db3._downgradeDatabaseFromWAL(dbPath);
}
catch (err) {
e = err;
}
assert.ok(e);
assert.isTrue(db3.isCorruptionError(e));
// The original files are untouched
assert.isTrue(await IOUtils.exists(dbPath + '-wal'));
assert.equal((await IOUtils.stat(dbPath)).size, origSize);
let header = await IOUtils.read(dbPath, { maxBytes: 20 });
assert.equal(header[18], 2);
});
it("should apply a WAL file left beside an already-converted database", async function () {
let dir = await getTempDirectory();
let dbPath = PathUtils.join(dir, 'test.sqlite');
let db = new Zotero.DBConnection(dbPath);
await db.queryAsync("PRAGMA journal_mode=WAL");
await db.queryAsync("CREATE TABLE foo (a INT)");
await db.queryAsync("INSERT INTO foo VALUES (1)");
await IOUtils.copy(dbPath + '-wal', dbPath + '.wal-copy');
await db.closeDatabase(true);
// Convert the database, and then restore the WAL file, simulating a conversion
// interrupted between the file swap and the WAL removal
let db2 = new Zotero.DBConnection(dbPath);
assert.isTrue(await db2._downgradeDatabaseFromWAL(dbPath));
await IOUtils.move(dbPath + '.wal-copy', dbPath + '-wal');
assert.isTrue(await db2._downgradeDatabaseFromWAL(dbPath));
assert.isFalse(await IOUtils.exists(dbPath + '-wal'));
let header = await IOUtils.read(dbPath, { maxBytes: 20 });
assert.equal(header[18], 1);
try {
assert.equal(await db2.valueQueryAsync("SELECT COUNT(*) FROM foo"), 1);
}
finally {
await db2.closeDatabase();
}
});
it("should convert the target of a symlinked database file", async function () {
if (Zotero.isWin) {
this.skip();
}
let dir = await getTempDirectory();
let targetPath = PathUtils.join(dir, 'target.sqlite');
let linkPath = PathUtils.join(dir, 'link.sqlite');
let db = new Zotero.DBConnection(targetPath);
await db.queryAsync("PRAGMA journal_mode=WAL");
await db.queryAsync("CREATE TABLE foo (a INT)");
await db.queryAsync("INSERT INTO foo VALUES (1)");
// Capture a non-empty WAL, which lives next to the symlink's target
await IOUtils.copy(targetPath, targetPath + '.copy');
await IOUtils.copy(targetPath + '-wal', targetPath + '.copy-wal');
await db.closeDatabase(true);
await IOUtils.move(targetPath + '.copy', targetPath);
await IOUtils.move(targetPath + '.copy-wal', targetPath + '-wal');
await Zotero.Utilities.Internal.subprocess('/bin/ln', ['-s', targetPath, linkPath]);
let db2 = new Zotero.DBConnection(linkPath);
assert.isTrue(await db2._downgradeDatabaseFromWAL(linkPath));
assert.isFalse(await IOUtils.exists(targetPath + '-wal'));
let header = await IOUtils.read(targetPath, { maxBytes: 20 });
assert.equal(header[18], 1);
assert.isTrue(Zotero.File.pathToFile(linkPath).isSymlink());
try {
assert.equal(await db2.valueQueryAsync("SELECT COUNT(*) FROM foo"), 1);
}
finally {
await db2.closeDatabase();
}
});
});
describe("#vacuum()", function () {
it("should vacuum the database with force option", async function () {
let result = await Zotero.DB.vacuum({ force: true });
@ -1142,3 +1302,9 @@ describe("Zotero.DB", function () {
});
});
});