mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-23 23:19:40 +01:00
463 lines
14 KiB
JavaScript
463 lines
14 KiB
JavaScript
/*
|
|
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 <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
// 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<number>}
|
|
*/
|
|
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<number>}
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
}
|