/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Copyright 2023 Roy T. Hashimoto. All Rights Reserved.
import * as VFS from "./VFS.js";
const SECTOR_SIZE = 4096;
// Each OPFS file begins with a fixed-size header with metadata. The
// contents of the file follow immediately after the header.
const HEADER_MAX_PATH_SIZE = 512;
const HEADER_FLAGS_SIZE = 4;
const HEADER_DIGEST_SIZE = 8;
const HEADER_CORPUS_SIZE = HEADER_MAX_PATH_SIZE + HEADER_FLAGS_SIZE;
const HEADER_OFFSET_FLAGS = HEADER_MAX_PATH_SIZE;
const HEADER_OFFSET_DIGEST = HEADER_CORPUS_SIZE;
const HEADER_OFFSET_DATA = SECTOR_SIZE;
// These file types are expected to persist in the file system outside
// a session. Other files will be removed on VFS start.
const PERSISTENT_FILE_TYPES =
VFS.SQLITE_OPEN_MAIN_DB |
VFS.SQLITE_OPEN_MAIN_JOURNAL |
VFS.SQLITE_OPEN_SUPER_JOURNAL |
VFS.SQLITE_OPEN_WAL;
const DEFAULT_CAPACITY = 6;
function log(...args) {
// console.debug(...args);
}
/**
* This VFS uses the updated Access Handle API with all synchronous methods
* on FileSystemSyncAccessHandle (instead of just read and write). It will
* work with the regular SQLite WebAssembly build, i.e. the one without
* Asyncify.
*/
export class AccessHandlePoolVFS extends VFS.Base {
// All the OPFS files the VFS uses are contained in one flat directory
// specified in the constructor. No other files should be written here.
#directoryPath;
#directoryHandle;
// The OPFS files all have randomly-generated names that do not match
// the SQLite files whose data they contain. This map links those names
// with their respective OPFS access handles.
#mapAccessHandleToName = new Map();
// When a SQLite file is associated with an OPFS file, that association
// is kept in #mapPathToAccessHandle. Each access handle is in exactly
// one of #mapPathToAccessHandle or #availableAccessHandles.
#mapPathToAccessHandle = new Map();
#availableAccessHandles = new Set();
#mapIdToFile = new Map();
constructor(directoryPath) {
super();
this.#directoryPath = directoryPath;
this.isReady = this.reset().then(async () => {
if (this.getCapacity() === 0) {
await this.addCapacity(DEFAULT_CAPACITY);
}
});
}
get name() {
return "AccessHandlePool";
}
xOpen(name, fileId, flags, pOutFlags) {
log(`xOpen ${name} ${fileId} 0x${flags.toString(16)}`);
try {
// First try to open a path that already exists in the file system.
const path = name ? this.#getPath(name) : Math.random().toString(36);
let accessHandle = this.#mapPathToAccessHandle.get(path);
if (!accessHandle && flags & VFS.SQLITE_OPEN_CREATE) {
// File not found so try to create it.
if (this.getSize() < this.getCapacity()) {
// Choose an unassociated OPFS file from the pool.
[accessHandle] = this.#availableAccessHandles.keys();
this.#setAssociatedPath(accessHandle, path, flags);
} else {
// Out of unassociated files. This can be fixed by calling
// addCapacity() from the application.
throw new Error("cannot create file");
}
}
if (!accessHandle) {
throw new Error("file not found");
}
// Subsequent methods are only passed the fileId, so make sure we have
// a way to get the file resources.
const file = { path, flags, accessHandle };
this.#mapIdToFile.set(fileId, file);
pOutFlags.setInt32(0, flags, true);
return VFS.SQLITE_OK;
} catch (e) {
console.error(e.message);
return VFS.SQLITE_CANTOPEN;
}
}
xClose(fileId) {
const file = this.#mapIdToFile.get(fileId);
if (file) {
log(`xClose ${file.path}`);
file.accessHandle.flush();
this.#mapIdToFile.delete(fileId);
if (file.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
this.#deletePath(file.path);
}
}
return VFS.SQLITE_OK;
}
xRead(fileId, pData, iOffset) {
const file = this.#mapIdToFile.get(fileId);
log(`xRead ${file.path} ${pData.byteLength} ${iOffset}`);
const nBytes = file.accessHandle.read(pData, {
at: HEADER_OFFSET_DATA + iOffset
});
if (nBytes < pData.byteLength) {
pData.fill(0, nBytes, pData.byteLength);
return VFS.SQLITE_IOERR_SHORT_READ;
}
return VFS.SQLITE_OK;
}
xWrite(fileId, pData, iOffset) {
const file = this.#mapIdToFile.get(fileId);
log(`xWrite ${file.path} ${pData.byteLength} ${iOffset}`);
const nBytes = file.accessHandle.write(pData, {
at: HEADER_OFFSET_DATA + iOffset
});
return nBytes === pData.byteLength ? VFS.SQLITE_OK : VFS.SQLITE_IOERR;
}
xTruncate(fileId, iSize) {
const file = this.#mapIdToFile.get(fileId);
log(`xTruncate ${file.path} ${iSize}`);
file.accessHandle.truncate(HEADER_OFFSET_DATA + iSize);
return VFS.SQLITE_OK;
}
xSync(fileId, flags) {
const file = this.#mapIdToFile.get(fileId);
log(`xSync ${file.path} ${flags}`);
file.accessHandle.flush();
return VFS.SQLITE_OK;
}
xFileSize(fileId, pSize64) {
const file = this.#mapIdToFile.get(fileId);
const size = file.accessHandle.getSize() - HEADER_OFFSET_DATA;
log(`xFileSize ${file.path} ${size}`);
pSize64.setBigInt64(0, BigInt(size), true);
return VFS.SQLITE_OK;
}
xSectorSize(fileId) {
log("xSectorSize", SECTOR_SIZE);
return SECTOR_SIZE;
}
xDeviceCharacteristics(fileId) {
log("xDeviceCharacteristics");
return VFS.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN;
}
xAccess(name, flags, pResOut) {
log(`xAccess ${name} ${flags}`);
const path = this.#getPath(name);
pResOut.setInt32(0, this.#mapPathToAccessHandle.has(path) ? 1 : 0, true);
return VFS.SQLITE_OK;
}
xDelete(name, syncDir) {
log(`xDelete ${name} ${syncDir}`);
const path = this.#getPath(name);
this.#deletePath(path);
return VFS.SQLITE_OK;
}
async close() {
await this.#releaseAccessHandles();
}
/**
* Release and reacquire all OPFS access handles. This must be called
* and awaited before any SQLite call that uses the VFS and also before
* any capacity changes.
*/
async reset() {
await this.isReady;
// All files are stored in a single directory.
let handle = await navigator.storage.getDirectory();
for (const d of this.#directoryPath.split("/")) {
if (d) {
handle = await handle.getDirectoryHandle(d, { create: true });
}
}
this.#directoryHandle = handle;
await this.#releaseAccessHandles();
await this.#acquireAccessHandles();
}
/**
* Returns the number of SQLite files in the file system.
* @returns {number}
*/
getSize() {
return this.#mapPathToAccessHandle.size;
}
/**
* Returns the maximum number of SQLite files the file system can hold.
* @returns {number}
*/
getCapacity() {
return this.#mapAccessHandleToName.size;
}
/**
* Increase the capacity of the file system by n.
* @param {number} n
* @returns {Promise}
*/
async addCapacity(n) {
for (let i = 0; i < n; ++i) {
const name = Math.random().toString(36).replace("0.", "");
const handle = await this.#directoryHandle.getFileHandle(name, {
create: true
});
const accessHandle = await handle.createSyncAccessHandle();
this.#mapAccessHandleToName.set(accessHandle, name);
this.#setAssociatedPath(accessHandle, "", 0);
}
return n;
}
/**
* Decrease the capacity of the file system by n. The capacity cannot be
* decreased to fewer than the current number of SQLite files in the
* file system.
* @param {number} n
* @returns {Promise}
*/
async removeCapacity(n) {
let nRemoved = 0;
for (const accessHandle of Array.from(this.#availableAccessHandles)) {
if (nRemoved == n || this.getSize() === this.getCapacity())
return nRemoved;
const name = this.#mapAccessHandleToName.get(accessHandle);
await accessHandle.close();
await this.#directoryHandle.removeEntry(name);
this.#mapAccessHandleToName.delete(accessHandle);
this.#availableAccessHandles.delete(accessHandle);
++nRemoved;
}
return nRemoved;
}
async #acquireAccessHandles() {
// Enumerate all the files in the directory.
const files = [];
for await (const [name, handle] of this.#directoryHandle) {
if (handle.kind === "file") {
files.push([name, handle]);
}
}
// Open access handles in parallel, separating associated and unassociated.
await Promise.all(
files.map(async ([name, handle]) => {
const accessHandle = await handle.createSyncAccessHandle();
this.#mapAccessHandleToName.set(accessHandle, name);
const path = this.#getAssociatedPath(accessHandle);
if (path) {
this.#mapPathToAccessHandle.set(path, accessHandle);
} else {
this.#availableAccessHandles.add(accessHandle);
}
})
);
}
#releaseAccessHandles() {
for (const accessHandle of this.#mapAccessHandleToName.keys()) {
accessHandle.close();
}
this.#mapAccessHandleToName.clear();
this.#mapPathToAccessHandle.clear();
this.#availableAccessHandles.clear();
}
/**
* Read and return the associated path from an OPFS file header.
* Empty string is returned for an unassociated OPFS file.
* @param accessHandle FileSystemSyncAccessHandle
* @returns {string} path or empty string
*/
#getAssociatedPath(accessHandle) {
// Read the path and digest of the path from the file.
const corpus = new Uint8Array(HEADER_CORPUS_SIZE);
accessHandle.read(corpus, { at: 0 });
// Delete files not expected to be present.
const dataView = new DataView(corpus.buffer, corpus.byteOffset);
const flags = dataView.getUint32(HEADER_OFFSET_FLAGS);
if (
corpus[0] &&
(flags & VFS.SQLITE_OPEN_DELETEONCLOSE ||
(flags & PERSISTENT_FILE_TYPES) === 0)
) {
console.warn(`Remove file with unexpected flags ${flags.toString(16)}`);
this.#setAssociatedPath(accessHandle, "", 0);
return "";
}
const fileDigest = new Uint32Array(HEADER_DIGEST_SIZE / 4);
accessHandle.read(fileDigest, { at: HEADER_OFFSET_DIGEST });
// Verify the digest.
const computedDigest = this.#computeDigest(corpus);
if (fileDigest.every((value, i) => value === computedDigest[i])) {
// Good digest. Decode the null-terminated path string.
const pathBytes = corpus.findIndex((value) => value === 0);
if (pathBytes === 0) {
// Ensure that unassociated files are empty. Unassociated files are
// truncated in #setAssociatedPath after the header is written. If
// an interruption occurs right before the truncation then garbage
// may remain in the file.
accessHandle.truncate(HEADER_OFFSET_DATA);
}
return new TextDecoder().decode(corpus.subarray(0, pathBytes));
} else {
// Bad digest. Repair this header.
console.warn("Disassociating file with bad digest.");
this.#setAssociatedPath(accessHandle, "", 0);
return "";
}
}
/**
* Set the path on an OPFS file header.
* @param accessHandle FileSystemSyncAccessHandle
* @param {string} path
* @param {number} flags
*/
#setAssociatedPath(accessHandle, path, flags) {
// Convert the path string to UTF-8.
const corpus = new Uint8Array(HEADER_CORPUS_SIZE);
const encodedResult = new TextEncoder().encodeInto(path, corpus);
if (encodedResult.written >= HEADER_MAX_PATH_SIZE) {
throw new Error("path too long");
}
// Add the creation flags.
const dataView = new DataView(corpus.buffer, corpus.byteOffset);
dataView.setUint32(HEADER_OFFSET_FLAGS, flags);
// Write the OPFS file header, including the digest.
const digest = this.#computeDigest(corpus);
accessHandle.write(corpus, { at: 0 });
accessHandle.write(digest, { at: HEADER_OFFSET_DIGEST });
accessHandle.flush();
if (path) {
this.#mapPathToAccessHandle.set(path, accessHandle);
this.#availableAccessHandles.delete(accessHandle);
} else {
// This OPFS file doesn't represent any SQLite file so it doesn't
// need to keep any data.
accessHandle.truncate(HEADER_OFFSET_DATA);
this.#availableAccessHandles.add(accessHandle);
}
}
/**
* We need a synchronous digest function so can't use WebCrypto.
* Adapted from https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
* @param {Uint8Array} corpus
* @returns {ArrayBuffer} 64-bit digest
*/
#computeDigest(corpus) {
if (!corpus[0]) {
// Optimization for deleted file.
return new Uint32Array([0xfecc5f80, 0xaccec037]);
}
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (const value of corpus) {
h1 = Math.imul(h1 ^ value, 2654435761);
h2 = Math.imul(h2 ^ value, 1597334677);
}
h1 =
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 =
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return new Uint32Array([h1 >>> 0, h2 >>> 0]);
}
/**
* Convert a bare filename, path, or URL to a UNIX-style path.
* @param {string|URL} nameOrURL
* @returns {string} path
*/
#getPath(nameOrURL) {
const url =
typeof nameOrURL === "string"
? new URL(nameOrURL, "file://localhost/")
: nameOrURL;
return url.pathname;
}
/**
* Remove the association between a path and an OPFS file.
* @param {string} path
*/
#deletePath(path) {
const accessHandle = this.#mapPathToAccessHandle.get(path);
if (accessHandle) {
// Un-associate the SQLite path from the OPFS file.
this.#mapPathToAccessHandle.delete(path);
this.#setAssociatedPath(accessHandle, "", 0);
}
}
}