summaryrefslogtreecommitdiffstats
path: root/ext/wasm/api/sqlite3-vfs-opfs-sahpool.c-pp.js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/wasm/api/sqlite3-vfs-opfs-sahpool.c-pp.js')
-rw-r--r--ext/wasm/api/sqlite3-vfs-opfs-sahpool.c-pp.js1287
1 files changed, 1287 insertions, 0 deletions
diff --git a/ext/wasm/api/sqlite3-vfs-opfs-sahpool.c-pp.js b/ext/wasm/api/sqlite3-vfs-opfs-sahpool.c-pp.js
new file mode 100644
index 0000000..870073c
--- /dev/null
+++ b/ext/wasm/api/sqlite3-vfs-opfs-sahpool.c-pp.js
@@ -0,0 +1,1287 @@
+//#ifnot target=node
+/*
+ 2023-07-14
+
+ The author disclaims copyright to this source code. In place of a
+ legal notice, here is a blessing:
+
+ * May you do good and not evil.
+ * May you find forgiveness for yourself and forgive others.
+ * May you share freely, never taking more than you give.
+
+ ***********************************************************************
+
+ This file holds a sqlite3_vfs backed by OPFS storage which uses a
+ different implementation strategy than the "opfs" VFS. This one is a
+ port of Roy Hashimoto's OPFS SyncAccessHandle pool:
+
+ https://github.com/rhashimoto/wa-sqlite/blob/master/src/examples/AccessHandlePoolVFS.js
+
+ As described at:
+
+ https://github.com/rhashimoto/wa-sqlite/discussions/67
+
+ with Roy's explicit permission to permit us to port his to our
+ infrastructure rather than having to clean-room reverse-engineer it:
+
+ https://sqlite.org/forum/forumpost/e140d84e71
+
+ Primary differences from the "opfs" VFS include:
+
+ - This one avoids the need for a sub-worker to synchronize
+ communication between the synchronous C API and the
+ only-partly-synchronous OPFS API.
+
+ - It does so by opening a fixed number of OPFS files at
+ library-level initialization time, obtaining SyncAccessHandles to
+ each, and manipulating those handles via the synchronous sqlite3_vfs
+ interface. If it cannot open them (e.g. they are already opened by
+ another tab) then the VFS will not be installed.
+
+ - Because of that, this one lacks all library-level concurrency
+ support.
+
+ - Also because of that, it does not require the SharedArrayBuffer,
+ so can function without the COOP/COEP HTTP response headers.
+
+ - It can hypothetically support Safari 16.4+, whereas the "opfs" VFS
+ requires v17 due to a subworker/storage bug in 16.x which makes it
+ incompatible with that VFS.
+
+ - This VFS requires the "semi-fully-sync" FileSystemSyncAccessHandle
+ (hereafter "SAH") APIs released with Chrome v108 (and all other
+ major browsers released since March 2023). If that API is not
+ detected, the VFS is not registered.
+*/
+globalThis.sqlite3ApiBootstrap.initializers.push(function(sqlite3){
+ 'use strict';
+ const toss = sqlite3.util.toss;
+ const toss3 = sqlite3.util.toss3;
+ const initPromises = Object.create(null);
+ const capi = sqlite3.capi;
+ const util = sqlite3.util;
+ const wasm = sqlite3.wasm;
+ // Config opts for the VFS...
+ const SECTOR_SIZE = 4096;
+ 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;
+ /* Bitmask of file types which may persist across sessions.
+ SQLITE_OPEN_xyz types not listed here may be inadvertently
+ left in OPFS but are treated as transient by this VFS and
+ they will be cleaned up during VFS init. */
+ const PERSISTENT_FILE_TYPES =
+ capi.SQLITE_OPEN_MAIN_DB |
+ capi.SQLITE_OPEN_MAIN_JOURNAL |
+ capi.SQLITE_OPEN_SUPER_JOURNAL |
+ capi.SQLITE_OPEN_WAL /* noting that WAL support is
+ unavailable in the WASM build.*/;
+
+ /** Subdirectory of the VFS's space where "opaque" (randomly-named)
+ files are stored. Changing this effectively invalidates the data
+ stored under older names (orphaning it), so don't do that. */
+ const OPAQUE_DIR_NAME = ".opaque";
+
+ /**
+ Returns short a string of random alphanumeric characters
+ suitable for use as a random filename.
+ */
+ const getRandomName = ()=>Math.random().toString(36).slice(2);
+
+ const textDecoder = new TextDecoder();
+ const textEncoder = new TextEncoder();
+
+ const optionDefaults = Object.assign(Object.create(null),{
+ name: 'opfs-sahpool',
+ directory: undefined /* derived from .name */,
+ initialCapacity: 6,
+ clearOnInit: false,
+ /* Logging verbosity 3+ == everything, 2 == warnings+errors, 1 ==
+ errors only. */
+ verbosity: 2
+ });
+
+ /** Logging routines, from most to least serious. */
+ const loggers = [
+ sqlite3.config.error,
+ sqlite3.config.warn,
+ sqlite3.config.log
+ ];
+ const log = sqlite3.config.log;
+ const warn = sqlite3.config.warn;
+ const error = sqlite3.config.error;
+
+ /* Maps (sqlite3_vfs*) to OpfsSAHPool instances */
+ const __mapVfsToPool = new Map();
+ const getPoolForVfs = (pVfs)=>__mapVfsToPool.get(pVfs);
+ const setPoolForVfs = (pVfs,pool)=>{
+ if(pool) __mapVfsToPool.set(pVfs, pool);
+ else __mapVfsToPool.delete(pVfs);
+ };
+ /* Maps (sqlite3_file*) to OpfsSAHPool instances */
+ const __mapSqlite3File = new Map();
+ const getPoolForPFile = (pFile)=>__mapSqlite3File.get(pFile);
+ const setPoolForPFile = (pFile,pool)=>{
+ if(pool) __mapSqlite3File.set(pFile, pool);
+ else __mapSqlite3File.delete(pFile);
+ };
+
+ /**
+ Impls for the sqlite3_io_methods methods. Maintenance reminder:
+ members are in alphabetical order to simplify finding them.
+ */
+ const ioMethods = {
+ xCheckReservedLock: function(pFile,pOut){
+ const pool = getPoolForPFile(pFile);
+ pool.log('xCheckReservedLock');
+ pool.storeErr();
+ wasm.poke32(pOut, 1);
+ return 0;
+ },
+ xClose: function(pFile){
+ const pool = getPoolForPFile(pFile);
+ pool.storeErr();
+ const file = pool.getOFileForS3File(pFile);
+ if(file) {
+ try{
+ pool.log(`xClose ${file.path}`);
+ pool.mapS3FileToOFile(pFile, false);
+ file.sah.flush();
+ if(file.flags & capi.SQLITE_OPEN_DELETEONCLOSE){
+ pool.deletePath(file.path);
+ }
+ }catch(e){
+ return pool.storeErr(e, capi.SQLITE_IOERR);
+ }
+ }
+ return 0;
+ },
+ xDeviceCharacteristics: function(pFile){
+ return capi.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN;
+ },
+ xFileControl: function(pFile, opId, pArg){
+ return capi.SQLITE_NOTFOUND;
+ },
+ xFileSize: function(pFile,pSz64){
+ const pool = getPoolForPFile(pFile);
+ pool.log(`xFileSize`);
+ const file = pool.getOFileForS3File(pFile);
+ const size = file.sah.getSize() - HEADER_OFFSET_DATA;
+ //log(`xFileSize ${file.path} ${size}`);
+ wasm.poke64(pSz64, BigInt(size));
+ return 0;
+ },
+ xLock: function(pFile,lockType){
+ const pool = getPoolForPFile(pFile);
+ pool.log(`xLock ${lockType}`);
+ pool.storeErr();
+ const file = pool.getOFileForS3File(pFile);
+ file.lockType = lockType;
+ return 0;
+ },
+ xRead: function(pFile,pDest,n,offset64){
+ const pool = getPoolForPFile(pFile);
+ pool.storeErr();
+ const file = pool.getOFileForS3File(pFile);
+ pool.log(`xRead ${file.path} ${n} @ ${offset64}`);
+ try {
+ const nRead = file.sah.read(
+ wasm.heap8u().subarray(pDest, pDest+n),
+ {at: HEADER_OFFSET_DATA + Number(offset64)}
+ );
+ if(nRead < n){
+ wasm.heap8u().fill(0, pDest + nRead, pDest + n);
+ return capi.SQLITE_IOERR_SHORT_READ;
+ }
+ return 0;
+ }catch(e){
+ return pool.storeErr(e, capi.SQLITE_IOERR);
+ }
+ },
+ xSectorSize: function(pFile){
+ return SECTOR_SIZE;
+ },
+ xSync: function(pFile,flags){
+ const pool = getPoolForPFile(pFile);
+ pool.log(`xSync ${flags}`);
+ pool.storeErr();
+ const file = pool.getOFileForS3File(pFile);
+ //log(`xSync ${file.path} ${flags}`);
+ try{
+ file.sah.flush();
+ return 0;
+ }catch(e){
+ return pool.storeErr(e, capi.SQLITE_IOERR);
+ }
+ },
+ xTruncate: function(pFile,sz64){
+ const pool = getPoolForPFile(pFile);
+ pool.log(`xTruncate ${sz64}`);
+ pool.storeErr();
+ const file = pool.getOFileForS3File(pFile);
+ //log(`xTruncate ${file.path} ${iSize}`);
+ try{
+ file.sah.truncate(HEADER_OFFSET_DATA + Number(sz64));
+ return 0;
+ }catch(e){
+ return pool.storeErr(e, capi.SQLITE_IOERR);
+ }
+ },
+ xUnlock: function(pFile,lockType){
+ const pool = getPoolForPFile(pFile);
+ pool.log('xUnlock');
+ const file = pool.getOFileForS3File(pFile);
+ file.lockType = lockType;
+ return 0;
+ },
+ xWrite: function(pFile,pSrc,n,offset64){
+ const pool = getPoolForPFile(pFile);
+ pool.storeErr();
+ const file = pool.getOFileForS3File(pFile);
+ pool.log(`xWrite ${file.path} ${n} ${offset64}`);
+ try{
+ const nBytes = file.sah.write(
+ wasm.heap8u().subarray(pSrc, pSrc+n),
+ { at: HEADER_OFFSET_DATA + Number(offset64) }
+ );
+ return n===nBytes ? 0 : toss("Unknown write() failure.");
+ }catch(e){
+ return pool.storeErr(e, capi.SQLITE_IOERR);
+ }
+ }
+ }/*ioMethods*/;
+
+ const opfsIoMethods = new capi.sqlite3_io_methods();
+ opfsIoMethods.$iVersion = 1;
+ sqlite3.vfs.installVfs({
+ io: {struct: opfsIoMethods, methods: ioMethods}
+ });
+
+ /**
+ Impls for the sqlite3_vfs methods. Maintenance reminder: members
+ are in alphabetical order to simplify finding them.
+ */
+ const vfsMethods = {
+ xAccess: function(pVfs,zName,flags,pOut){
+ //log(`xAccess ${wasm.cstrToJs(zName)}`);
+ const pool = getPoolForVfs(pVfs);
+ pool.storeErr();
+ try{
+ const name = pool.getPath(zName);
+ wasm.poke32(pOut, pool.hasFilename(name) ? 1 : 0);
+ }catch(e){
+ /*ignored*/
+ wasm.poke32(pOut, 0);
+ }
+ return 0;
+ },
+ xCurrentTime: function(pVfs,pOut){
+ wasm.poke(pOut, 2440587.5 + (new Date().getTime()/86400000),
+ 'double');
+ return 0;
+ },
+ xCurrentTimeInt64: function(pVfs,pOut){
+ wasm.poke(pOut, (2440587.5 * 86400000) + new Date().getTime(),
+ 'i64');
+ return 0;
+ },
+ xDelete: function(pVfs, zName, doSyncDir){
+ const pool = getPoolForVfs(pVfs);
+ pool.log(`xDelete ${wasm.cstrToJs(zName)}`);
+ pool.storeErr();
+ try{
+ pool.deletePath(pool.getPath(zName));
+ return 0;
+ }catch(e){
+ pool.storeErr(e);
+ return capi.SQLITE_IOERR_DELETE;
+ }
+ },
+ xFullPathname: function(pVfs,zName,nOut,pOut){
+ //const pool = getPoolForVfs(pVfs);
+ //pool.log(`xFullPathname ${wasm.cstrToJs(zName)}`);
+ const i = wasm.cstrncpy(pOut, zName, nOut);
+ return i<nOut ? 0 : capi.SQLITE_CANTOPEN;
+ },
+ xGetLastError: function(pVfs,nOut,pOut){
+ const pool = getPoolForVfs(pVfs);
+ const e = pool.popErr();
+ pool.log(`xGetLastError ${nOut} e =`,e);
+ if(e){
+ const scope = wasm.scopedAllocPush();
+ try{
+ const [cMsg, n] = wasm.scopedAllocCString(e.message, true);
+ wasm.cstrncpy(pOut, cMsg, nOut);
+ if(n > nOut) wasm.poke8(pOut + nOut - 1, 0);
+ }catch(e){
+ return capi.SQLITE_NOMEM;
+ }finally{
+ wasm.scopedAllocPop(scope);
+ }
+ }
+ return e ? (e.sqlite3Rc || capi.SQLITE_IOERR) : 0;
+ },
+ //xSleep is optionally defined below
+ xOpen: function f(pVfs, zName, pFile, flags, pOutFlags){
+ const pool = getPoolForVfs(pVfs);
+ try{
+ pool.log(`xOpen ${wasm.cstrToJs(zName)} ${flags}`);
+ // First try to open a path that already exists in the file system.
+ const path = (zName && wasm.peek8(zName))
+ ? pool.getPath(zName)
+ : getRandomName();
+ let sah = pool.getSAHForPath(path);
+ if(!sah && (flags & capi.SQLITE_OPEN_CREATE)) {
+ // File not found so try to create it.
+ if(pool.getFileCount() < pool.getCapacity()) {
+ // Choose an unassociated OPFS file from the pool.
+ sah = pool.nextAvailableSAH();
+ pool.setAssociatedPath(sah, path, flags);
+ }else{
+ // File pool is full.
+ toss('SAH pool is full. Cannot create file',path);
+ }
+ }
+ if(!sah){
+ toss('file not found:',path);
+ }
+ // Subsequent I/O methods are only passed the sqlite3_file
+ // pointer, so map the relevant info we need to that pointer.
+ const file = {path, flags, sah};
+ pool.mapS3FileToOFile(pFile, file);
+ file.lockType = capi.SQLITE_LOCK_NONE;
+ const sq3File = new capi.sqlite3_file(pFile);
+ sq3File.$pMethods = opfsIoMethods.pointer;
+ sq3File.dispose();
+ wasm.poke32(pOutFlags, flags);
+ return 0;
+ }catch(e){
+ pool.storeErr(e);
+ return capi.SQLITE_CANTOPEN;
+ }
+ }/*xOpen()*/
+ }/*vfsMethods*/;
+
+ /**
+ Creates and initializes an sqlite3_vfs instance for an
+ OpfsSAHPool. The argument is the VFS's name (JS string).
+
+ Throws if the VFS name is already registered or if something
+ goes terribly wrong via sqlite3.vfs.installVfs().
+
+ Maintenance reminder: the only detail about the returned object
+ which is specific to any given OpfsSAHPool instance is the $zName
+ member. All other state is identical.
+ */
+ const createOpfsVfs = function(vfsName){
+ if( sqlite3.capi.sqlite3_vfs_find(vfsName)){
+ toss3("VFS name is already registered:", vfsName);
+ }
+ const opfsVfs = new capi.sqlite3_vfs();
+ /* We fetch the default VFS so that we can inherit some
+ methods from it. */
+ const pDVfs = capi.sqlite3_vfs_find(null);
+ const dVfs = pDVfs
+ ? new capi.sqlite3_vfs(pDVfs)
+ : null /* dVfs will be null when sqlite3 is built with
+ SQLITE_OS_OTHER. */;
+ opfsVfs.$iVersion = 2/*yes, two*/;
+ opfsVfs.$szOsFile = capi.sqlite3_file.structInfo.sizeof;
+ opfsVfs.$mxPathname = HEADER_MAX_PATH_SIZE;
+ opfsVfs.addOnDispose(
+ opfsVfs.$zName = wasm.allocCString(vfsName),
+ ()=>setPoolForVfs(opfsVfs.pointer, 0)
+ );
+
+ if(dVfs){
+ /* Inherit certain VFS members from the default VFS,
+ if available. */
+ opfsVfs.$xRandomness = dVfs.$xRandomness;
+ opfsVfs.$xSleep = dVfs.$xSleep;
+ dVfs.dispose();
+ }
+ if(!opfsVfs.$xRandomness && !vfsMethods.xRandomness){
+ /* If the default VFS has no xRandomness(), add a basic JS impl... */
+ vfsMethods.xRandomness = function(pVfs, nOut, pOut){
+ const heap = wasm.heap8u();
+ let i = 0;
+ for(; i < nOut; ++i) heap[pOut + i] = (Math.random()*255000) & 0xFF;
+ return i;
+ };
+ }
+ if(!opfsVfs.$xSleep && !vfsMethods.xSleep){
+ vfsMethods.xSleep = (pVfs,ms)=>0;
+ }
+ sqlite3.vfs.installVfs({
+ vfs: {struct: opfsVfs, methods: vfsMethods}
+ });
+ return opfsVfs;
+ };
+
+ /**
+ Class for managing OPFS-related state for the
+ OPFS SharedAccessHandle Pool sqlite3_vfs.
+ */
+ class OpfsSAHPool {
+ /* OPFS dir in which VFS metadata is stored. */
+ vfsDir;
+ /* Directory handle to this.vfsDir. */
+ #dhVfsRoot;
+ /* Directory handle to the subdir of this.#dhVfsRoot which holds
+ the randomly-named "opaque" files. This subdir exists in the
+ hope that we can eventually support client-created files in
+ this.#dhVfsRoot. */
+ #dhOpaque;
+ /* Directory handle to this.dhVfsRoot's parent dir. Needed
+ for a VFS-wipe op. */
+ #dhVfsParent;
+ /* Maps SAHs to their opaque file names. */
+ #mapSAHToName = new Map();
+ /* Maps client-side file names to SAHs. */
+ #mapFilenameToSAH = new Map();
+ /* Set of currently-unused SAHs. */
+ #availableSAH = new Set();
+ /* Maps (sqlite3_file*) to xOpen's file objects. */
+ #mapS3FileToOFile_ = new Map();
+
+ /* Maps SAH to an abstract File Object which contains
+ various metadata about that handle. */
+ //#mapSAHToMeta = new Map();
+
+ /** Buffer used by [sg]etAssociatedPath(). */
+ #apBody = new Uint8Array(HEADER_CORPUS_SIZE);
+ // DataView for this.#apBody
+ #dvBody;
+
+ // associated sqlite3_vfs instance
+ #cVfs;
+
+ // Logging verbosity. See optionDefaults.verbosity.
+ #verbosity;
+
+ constructor(options = Object.create(null)){
+ this.#verbosity = options.verbosity ?? optionDefaults.verbosity;
+ this.vfsName = options.name || optionDefaults.name;
+ this.#cVfs = createOpfsVfs(this.vfsName);
+ setPoolForVfs(this.#cVfs.pointer, this);
+ this.vfsDir = options.directory || ("."+this.vfsName);
+ this.#dvBody =
+ new DataView(this.#apBody.buffer, this.#apBody.byteOffset);
+ this.isReady = this
+ .reset(!!(options.clearOnInit ?? optionDefaults.clearOnInit))
+ .then(()=>{
+ if(this.$error) throw this.$error;
+ return this.getCapacity()
+ ? Promise.resolve(undefined)
+ : this.addCapacity(options.initialCapacity
+ || optionDefaults.initialCapacity);
+ });
+ }
+
+ #logImpl(level,...args){
+ if(this.#verbosity>level) loggers[level](this.vfsName+":",...args);
+ };
+ log(...args){this.#logImpl(2, ...args)};
+ warn(...args){this.#logImpl(1, ...args)};
+ error(...args){this.#logImpl(0, ...args)};
+
+ getVfs(){return this.#cVfs}
+
+ /* Current pool capacity. */
+ getCapacity(){return this.#mapSAHToName.size}
+
+ /* Current number of in-use files from pool. */
+ getFileCount(){return this.#mapFilenameToSAH.size}
+
+ /* Returns an array of the names of all
+ currently-opened client-specified filenames. */
+ getFileNames(){
+ const rc = [];
+ const iter = this.#mapFilenameToSAH.keys();
+ for(const n of iter) rc.push(n);
+ return rc;
+ }
+
+// #createFileObject(sah,clientName,opaqueName){
+// const f = Object.assign(Object.create(null),{
+// clientName, opaqueName
+// });
+// this.#mapSAHToMeta.set(sah, f);
+// return f;
+// }
+// #unmapFileObject(sah){
+// this.#mapSAHToMeta.delete(sah);
+// }
+
+ /**
+ Adds n files to the pool's capacity. This change is
+ persistent across settings. Returns a Promise which resolves
+ to the new capacity.
+ */
+ async addCapacity(n){
+ for(let i = 0; i < n; ++i){
+ const name = getRandomName();
+ const h = await this.#dhOpaque.getFileHandle(name, {create:true});
+ const ah = await h.createSyncAccessHandle();
+ this.#mapSAHToName.set(ah,name);
+ this.setAssociatedPath(ah, '', 0);
+ //this.#createFileObject(ah,undefined,name);
+ }
+ return this.getCapacity();
+ }
+
+ /**
+ Reduce capacity by n, but can only reduce up to the limit
+ of currently-available SAHs. Returns a Promise which resolves
+ to the number of slots really removed.
+ */
+ async reduceCapacity(n){
+ let nRm = 0;
+ for(const ah of Array.from(this.#availableSAH)){
+ if(nRm === n || this.getFileCount() === this.getCapacity()){
+ break;
+ }
+ const name = this.#mapSAHToName.get(ah);
+ //this.#unmapFileObject(ah);
+ ah.close();
+ await this.#dhOpaque.removeEntry(name);
+ this.#mapSAHToName.delete(ah);
+ this.#availableSAH.delete(ah);
+ ++nRm;
+ }
+ return nRm;
+ }
+
+ /**
+ Releases all currently-opened SAHs. The only legal
+ operation after this is acquireAccessHandles().
+ */
+ releaseAccessHandles(){
+ for(const ah of this.#mapSAHToName.keys()) ah.close();
+ this.#mapSAHToName.clear();
+ this.#mapFilenameToSAH.clear();
+ this.#availableSAH.clear();
+ }
+
+ /**
+ Opens all files under this.vfsDir/this.#dhOpaque and acquires
+ a SAH for each. returns a Promise which resolves to no value
+ but completes once all SAHs are acquired. If acquiring an SAH
+ throws, SAHPool.$error will contain the corresponding
+ exception.
+
+ If clearFiles is true, the client-stored state of each file is
+ cleared when its handle is acquired, including its name, flags,
+ and any data stored after the metadata block.
+ */
+ async acquireAccessHandles(clearFiles){
+ const files = [];
+ for await (const [name,h] of this.#dhOpaque){
+ if('file'===h.kind){
+ files.push([name,h]);
+ }
+ }
+ return Promise.all(files.map(async([name,h])=>{
+ try{
+ const ah = await h.createSyncAccessHandle()
+ this.#mapSAHToName.set(ah, name);
+ if(clearFiles){
+ ah.truncate(HEADER_OFFSET_DATA);
+ this.setAssociatedPath(ah, '', 0);
+ }else{
+ const path = this.getAssociatedPath(ah);
+ if(path){
+ this.#mapFilenameToSAH.set(path, ah);
+ }else{
+ this.#availableSAH.add(ah);
+ }
+ }
+ }catch(e){
+ this.storeErr(e);
+ this.releaseAccessHandles();
+ throw e;
+ }
+ }));
+ }
+
+ /**
+ Given an SAH, returns the client-specified name of
+ that file by extracting it from the SAH's header.
+
+ On error, it disassociates SAH from the pool and
+ returns an empty string.
+ */
+ getAssociatedPath(sah){
+ sah.read(this.#apBody, {at: 0});
+ // Delete any unexpected files left over by previous
+ // untimely errors...
+ const flags = this.#dvBody.getUint32(HEADER_OFFSET_FLAGS);
+ if(this.#apBody[0] &&
+ ((flags & capi.SQLITE_OPEN_DELETEONCLOSE) ||
+ (flags & PERSISTENT_FILE_TYPES)===0)){
+ warn(`Removing file with unexpected flags ${flags.toString(16)}`,
+ this.#apBody);
+ this.setAssociatedPath(sah, '', 0);
+ return '';
+ }
+
+ const fileDigest = new Uint32Array(HEADER_DIGEST_SIZE / 4);
+ sah.read(fileDigest, {at: HEADER_OFFSET_DIGEST});
+ const compDigest = this.computeDigest(this.#apBody);
+ if(fileDigest.every((v,i) => v===compDigest[i])){
+ // Valid digest
+ const pathBytes = this.#apBody.findIndex((v)=>0===v);
+ if(0===pathBytes){
+ // This file is unassociated, so truncate it to avoid
+ // leaving stale db data laying around.
+ sah.truncate(HEADER_OFFSET_DATA);
+ }
+ return pathBytes
+ ? textDecoder.decode(this.#apBody.subarray(0,pathBytes))
+ : '';
+ }else{
+ // Invalid digest
+ warn('Disassociating file with bad digest.');
+ this.setAssociatedPath(sah, '', 0);
+ return '';
+ }
+ }
+
+ /**
+ Stores the given client-defined path and SQLITE_OPEN_xyz flags
+ into the given SAH. If path is an empty string then the file is
+ disassociated from the pool but its previous name is preserved
+ in the metadata.
+ */
+ setAssociatedPath(sah, path, flags){
+ const enc = textEncoder.encodeInto(path, this.#apBody);
+ if(HEADER_MAX_PATH_SIZE <= enc.written + 1/*NUL byte*/){
+ toss("Path too long:",path);
+ }
+ this.#apBody.fill(0, enc.written, HEADER_MAX_PATH_SIZE);
+ this.#dvBody.setUint32(HEADER_OFFSET_FLAGS, flags);
+
+ const digest = this.computeDigest(this.#apBody);
+ sah.write(this.#apBody, {at: 0});
+ sah.write(digest, {at: HEADER_OFFSET_DIGEST});
+ sah.flush();
+
+ if(path){
+ this.#mapFilenameToSAH.set(path, sah);
+ this.#availableSAH.delete(sah);
+ }else{
+ // This is not a persistent file, so eliminate the contents.
+ sah.truncate(HEADER_OFFSET_DATA);
+ this.#availableSAH.add(sah);
+ }
+ }
+
+ /**
+ Computes a digest for the given byte array and returns it as a
+ two-element Uint32Array. This digest gets stored in the
+ metadata for each file as a validation check. Changing this
+ algorithm invalidates all existing databases for this VFS, so
+ don't do that.
+ */
+ computeDigest(byteArray){
+ let h1 = 0xdeadbeef;
+ let h2 = 0x41c6ce57;
+ for(const v of byteArray){
+ h1 = 31 * h1 + (v * 307);
+ h2 = 31 * h2 + (v * 307);
+ }
+ return new Uint32Array([h1>>>0, h2>>>0]);
+ }
+
+ /**
+ Re-initializes the state of the SAH pool, releasing and
+ re-acquiring all handles.
+
+ See acquireAccessHandles() for the specifics of the clearFiles
+ argument.
+ */
+ async reset(clearFiles){
+ await this.isReady;
+ let h = await navigator.storage.getDirectory();
+ let prev, prevName;
+ for(const d of this.vfsDir.split('/')){
+ if(d){
+ prev = h;
+ h = await h.getDirectoryHandle(d,{create:true});
+ }
+ }
+ this.#dhVfsRoot = h;
+ this.#dhVfsParent = prev;
+ this.#dhOpaque = await this.#dhVfsRoot.getDirectoryHandle(
+ OPAQUE_DIR_NAME,{create:true}
+ );
+ this.releaseAccessHandles();
+ return this.acquireAccessHandles(clearFiles);
+ }
+
+ /**
+ Returns the pathname part of the given argument,
+ which may be any of:
+
+ - a URL object
+ - A JS string representing a file name
+ - Wasm C-string representing a file name
+
+ All "../" parts and duplicate slashes are resolve/removed from
+ the returned result.
+ */
+ getPath(arg) {
+ if(wasm.isPtr(arg)) arg = wasm.cstrToJs(arg);
+ return ((arg instanceof URL)
+ ? arg
+ : new URL(arg, 'file://localhost/')).pathname;
+ }
+
+ /**
+ Removes the association of the given client-specified file
+ name (JS string) from the pool. Returns true if a mapping
+ is found, else false.
+ */
+ deletePath(path) {
+ const sah = this.#mapFilenameToSAH.get(path);
+ if(sah) {
+ // Un-associate the name from the SAH.
+ this.#mapFilenameToSAH.delete(path);
+ this.setAssociatedPath(sah, '', 0);
+ }
+ return !!sah;
+ }
+
+ /**
+ Sets e (an Error object) as this object's current error. Pass a
+ falsy (or no) value to clear it. If code is truthy it is
+ assumed to be an SQLITE_xxx result code, defaulting to
+ SQLITE_IOERR if code is falsy.
+
+ Returns the 2nd argument.
+ */
+ storeErr(e,code){
+ if(e){
+ e.sqlite3Rc = code || capi.SQLITE_IOERR;
+ this.error(e);
+ }
+ this.$error = e;
+ return code;
+ }
+ /**
+ Pops this object's Error object and returns
+ it (a falsy value if no error is set).
+ */
+ popErr(){
+ const rc = this.$error;
+ this.$error = undefined;
+ return rc;
+ }
+
+ /**
+ Returns the next available SAH without removing
+ it from the set.
+ */
+ nextAvailableSAH(){
+ const [rc] = this.#availableSAH.keys();
+ return rc;
+ }
+
+ /**
+ Given an (sqlite3_file*), returns the mapped
+ xOpen file object.
+ */
+ getOFileForS3File(pFile){
+ return this.#mapS3FileToOFile_.get(pFile);
+ }
+ /**
+ Maps or unmaps (if file is falsy) the given (sqlite3_file*)
+ to an xOpen file object and to this pool object.
+ */
+ mapS3FileToOFile(pFile,file){
+ if(file){
+ this.#mapS3FileToOFile_.set(pFile, file);
+ setPoolForPFile(pFile, this);
+ }else{
+ this.#mapS3FileToOFile_.delete(pFile);
+ setPoolForPFile(pFile, false);
+ }
+ }
+
+ /**
+ Returns true if the given client-defined file name is in this
+ object's name-to-SAH map.
+ */
+ hasFilename(name){
+ return this.#mapFilenameToSAH.has(name)
+ }
+
+ /**
+ Returns the SAH associated with the given
+ client-defined file name.
+ */
+ getSAHForPath(path){
+ return this.#mapFilenameToSAH.get(path);
+ }
+
+ /**
+ Removes this object's sqlite3_vfs registration and shuts down
+ this object, releasing all handles, mappings, and whatnot,
+ including deleting its data directory. There is currently no
+ way to "revive" the object and reaquire its resources.
+
+ This function is intended primarily for testing.
+
+ Resolves to true if it did its job, false if the
+ VFS has already been shut down.
+ */
+ async removeVfs(){
+ if(!this.#cVfs.pointer || !this.#dhOpaque) return false;
+ capi.sqlite3_vfs_unregister(this.#cVfs.pointer);
+ this.#cVfs.dispose();
+ try{
+ this.releaseAccessHandles();
+ await this.#dhVfsRoot.removeEntry(OPAQUE_DIR_NAME, {recursive: true});
+ this.#dhOpaque = undefined;
+ await this.#dhVfsParent.removeEntry(
+ this.#dhVfsRoot.name, {recursive: true}
+ );
+ this.#dhVfsRoot = this.#dhVfsParent = undefined;
+ }catch(e){
+ sqlite3.config.error(this.vfsName,"removeVfs() failed:",e);
+ /*otherwise ignored - there is no recovery strategy*/
+ }
+ return true;
+ }
+
+
+ //! Documented elsewhere in this file.
+ exportFile(name){
+ const sah = this.#mapFilenameToSAH.get(name) || toss("File not found:",name);
+ const n = sah.getSize() - HEADER_OFFSET_DATA;
+ const b = new Uint8Array(n>0 ? n : 0);
+ if(n>0){
+ const nRead = sah.read(b, {at: HEADER_OFFSET_DATA});
+ if(nRead != n){
+ toss("Expected to read "+n+" bytes but read "+nRead+".");
+ }
+ }
+ return b;
+ }
+
+ //! Impl for importDb() when its 2nd arg is a function.
+ async importDbChunked(name, callback){
+ const sah = this.#mapFilenameToSAH.get(name)
+ || this.nextAvailableSAH()
+ || toss("No available handles to import to.");
+ sah.truncate(0);
+ let nWrote = 0, chunk, checkedHeader = false, err = false;
+ try{
+ while( undefined !== (chunk = await callback()) ){
+ if(chunk instanceof ArrayBuffer) chunk = new Uint8Array(chunk);
+ if( 0===nWrote && chunk.byteLength>=15 ){
+ util.affirmDbHeader(chunk);
+ checkedHeader = true;
+ }
+ sah.write(chunk, {at: HEADER_OFFSET_DATA + nWrote});
+ nWrote += chunk.byteLength;
+ }
+ if( nWrote < 512 || 0!==nWrote % 512 ){
+ toss("Input size",nWrote,"is not correct for an SQLite database.");
+ }
+ if( !checkedHeader ){
+ const header = new Uint8Array(20);
+ sah.read( header, {at: 0} );
+ util.affirmDbHeader( header );
+ }
+ sah.write(new Uint8Array([1,1]), {
+ at: HEADER_OFFSET_DATA + 18
+ }/*force db out of WAL mode*/);
+ }catch(e){
+ this.setAssociatedPath(sah, '', 0);
+ throw e;
+ }
+ this.setAssociatedPath(sah, name, capi.SQLITE_OPEN_MAIN_DB);
+ return nWrote;
+ }
+
+ //! Documented elsewhere in this file.
+ importDb(name, bytes){
+ if( bytes instanceof ArrayBuffer ) bytes = new Uint8Array(bytes);
+ else if( bytes instanceof Function ) return this.importDbChunked(name, bytes);
+ const sah = this.#mapFilenameToSAH.get(name)
+ || this.nextAvailableSAH()
+ || toss("No available handles to import to.");
+ const n = bytes.byteLength;
+ if(n<512 || n%512!=0){
+ toss("Byte array size is invalid for an SQLite db.");
+ }
+ const header = "SQLite format 3";
+ for(let i = 0; i < header.length; ++i){
+ if( header.charCodeAt(i) !== bytes[i] ){
+ toss("Input does not contain an SQLite database header.");
+ }
+ }
+ const nWrote = sah.write(bytes, {at: HEADER_OFFSET_DATA});
+ if(nWrote != n){
+ this.setAssociatedPath(sah, '', 0);
+ toss("Expected to write "+n+" bytes but wrote "+nWrote+".");
+ }else{
+ sah.write(new Uint8Array([1,1]), {at: HEADER_OFFSET_DATA+18}
+ /* force db out of WAL mode */);
+ this.setAssociatedPath(sah, name, capi.SQLITE_OPEN_MAIN_DB);
+ }
+ return nWrote;
+ }
+
+ }/*class OpfsSAHPool*/;
+
+
+ /**
+ A OpfsSAHPoolUtil instance is exposed to clients in order to
+ manipulate an OpfsSAHPool object without directly exposing that
+ object and allowing for some semantic changes compared to that
+ class.
+
+ Class docs are in the client-level docs for
+ installOpfsSAHPoolVfs().
+ */
+ class OpfsSAHPoolUtil {
+ /* This object's associated OpfsSAHPool. */
+ #p;
+
+ constructor(sahPool){
+ this.#p = sahPool;
+ this.vfsName = sahPool.vfsName;
+ }
+
+ async addCapacity(n){ return this.#p.addCapacity(n) }
+
+ async reduceCapacity(n){ return this.#p.reduceCapacity(n) }
+
+ getCapacity(){ return this.#p.getCapacity(this.#p) }
+
+ getFileCount(){ return this.#p.getFileCount() }
+ getFileNames(){ return this.#p.getFileNames() }
+
+ async reserveMinimumCapacity(min){
+ const c = this.#p.getCapacity();
+ return (c < min) ? this.#p.addCapacity(min - c) : c;
+ }
+
+ exportFile(name){ return this.#p.exportFile(name) }
+
+ importDb(name, bytes){ return this.#p.importDb(name,bytes) }
+
+ async wipeFiles(){ return this.#p.reset(true) }
+
+ unlink(filename){ return this.#p.deletePath(filename) }
+
+ async removeVfs(){ return this.#p.removeVfs() }
+
+ }/* class OpfsSAHPoolUtil */;
+
+ /**
+ Returns a resolved Promise if the current environment
+ has a "fully-sync" SAH impl, else a rejected Promise.
+ */
+ const apiVersionCheck = async ()=>{
+ const dh = await navigator.storage.getDirectory();
+ const fn = '.opfs-sahpool-sync-check-'+getRandomName();
+ const fh = await dh.getFileHandle(fn, { create: true });
+ const ah = await fh.createSyncAccessHandle();
+ const close = ah.close();
+ await close;
+ await dh.removeEntry(fn);
+ if(close?.then){
+ toss("The local OPFS API is too old for opfs-sahpool:",
+ "it has an async FileSystemSyncAccessHandle.close() method.");
+ }
+ return true;
+ };
+
+ /** Only for testing a rejection case. */
+ let instanceCounter = 0;
+
+ /**
+ installOpfsSAHPoolVfs() asynchronously initializes the OPFS
+ SyncAccessHandle (a.k.a. SAH) Pool VFS. It returns a Promise which
+ either resolves to a utility object described below or rejects with
+ an Error value.
+
+ Initialization of this VFS is not automatic because its
+ registration requires that it lock all resources it
+ will potentially use, even if client code does not want
+ to use them. That, in turn, can lead to locking errors
+ when, for example, one page in a given origin has loaded
+ this VFS but does not use it, then another page in that
+ origin tries to use the VFS. If the VFS were automatically
+ registered, the second page would fail to load the VFS
+ due to OPFS locking errors.
+
+ If this function is called more than once with a given "name"
+ option (see below), it will return the same Promise. Calls for
+ different names will return different Promises which resolve to
+ independent objects and refer to different VFS registrations.
+
+ On success, the resulting Promise resolves to a utility object
+ which can be used to query and manipulate the pool. Its API is
+ described at the end of these docs.
+
+ This function accepts an options object to configure certain
+ parts but it is only acknowledged for the very first call and
+ ignored for all subsequent calls.
+
+ The options, in alphabetical order:
+
+ - `clearOnInit`: (default=false) if truthy, contents and filename
+ mapping are removed from each SAH it is acquired during
+ initalization of the VFS, leaving the VFS's storage in a pristine
+ state. Use this only for databases which need not survive a page
+ reload.
+
+ - `initialCapacity`: (default=6) Specifies the default capacity of
+ the VFS. This should not be set unduly high because the VFS has
+ to open (and keep open) a file for each entry in the pool. This
+ setting only has an effect when the pool is initially empty. It
+ does not have any effect if a pool already exists.
+
+ - `directory`: (default="."+`name`) Specifies the OPFS directory
+ name in which to store metadata for the `"opfs-sahpool"`
+ sqlite3_vfs. Only one instance of this VFS can be installed per
+ JavaScript engine, and any two engines with the same storage
+ directory name will collide with each other, leading to locking
+ errors and the inability to register the VFS in the second and
+ subsequent engine. Using a different directory name for each
+ application enables different engines in the same HTTP origin to
+ co-exist, but their data are invisible to each other. Changing
+ this name will effectively orphan any databases stored under
+ previous names. The default is unspecified but descriptive. This
+ option may contain multiple path elements, e.g. "foo/bar/baz",
+ and they are created automatically. In practice there should be
+ no driving need to change this. ACHTUNG: all files in this
+ directory are assumed to be managed by the VFS. Do not place
+ other files in that directory, as they may be deleted or
+ otherwise modified by the VFS.
+
+ - `name`: (default="opfs-sahpool") sets the name to register this
+ VFS under. Normally this should not be changed, but it is
+ possible to register this VFS under multiple names so long as
+ each has its own separate directory to work from. The storage for
+ each is invisible to all others. The name must be a string
+ compatible with `sqlite3_vfs_register()` and friends and suitable
+ for use in URI-style database file names.
+
+ Achtung: if a custom `name` is provided, a custom `directory`
+ must also be provided if any other instance is registered with
+ the default directory. If no directory is explicitly provided
+ then a directory name is synthesized from the `name` option.
+
+ Peculiarities of this VFS:
+
+ - Paths given to it _must_ be absolute. Relative paths will not
+ be properly recognized. This is arguably a bug but correcting it
+ requires some hoop-jumping in routines which have no business
+ doing tricks.
+
+ - It is possible to install multiple instances under different
+ names, each sandboxed from one another inside their own private
+ directory. This feature exists primarily as a way for disparate
+ applications within a given HTTP origin to use this VFS without
+ introducing locking issues between them.
+
+
+ The API for the utility object passed on by this function's
+ Promise, in alphabetical order...
+
+ - [async] number addCapacity(n)
+
+ Adds `n` entries to the current pool. This change is persistent
+ across sessions so should not be called automatically at each app
+ startup (but see `reserveMinimumCapacity()`). Its returned Promise
+ resolves to the new capacity. Because this operation is necessarily
+ asynchronous, the C-level VFS API cannot call this on its own as
+ needed.
+
+ - byteArray exportFile(name)
+
+ Synchronously reads the contents of the given file into a Uint8Array
+ and returns it. This will throw if the given name is not currently
+ in active use or on I/O error. Note that the given name is _not_
+ visible directly in OPFS (or, if it is, it's not from this VFS).
+
+ - number getCapacity()
+
+ Returns the number of files currently contained
+ in the SAH pool. The default capacity is only large enough for one
+ or two databases and their associated temp files.
+
+ - number getFileCount()
+
+ Returns the number of files from the pool currently allocated to
+ slots. This is not the same as the files being "opened".
+
+ - array getFileNames()
+
+ Returns an array of the names of the files currently allocated to
+ slots. This list is the same length as getFileCount().
+
+ - void importDb(name, bytes)
+
+ Imports the contents of an SQLite database, provided as a byte
+ array or ArrayBuffer, under the given name, overwriting any
+ existing content. Throws if the pool has no available file slots,
+ on I/O error, or if the input does not appear to be a
+ database. In the latter case, only a cursory examination is made.
+ Note that this routine is _only_ for importing database files,
+ not arbitrary files, the reason being that this VFS will
+ automatically clean up any non-database files so importing them
+ is pointless.
+
+ If passed a function for its second argument, its behavior
+ changes to asynchronous and it imports its data in chunks fed to
+ it by the given callback function. It calls the callback (which
+ may be async) repeatedly, expecting either a Uint8Array or
+ ArrayBuffer (to denote new input) or undefined (to denote
+ EOF). For so long as the callback continues to return
+ non-undefined, it will append incoming data to the given
+ VFS-hosted database file. The result of the resolved Promise when
+ called this way is the size of the resulting database.
+
+ On succes this routine rewrites the database header bytes in the
+ output file (not the input array) to force disabling of WAL mode.
+
+ On a write error, the handle is removed from the pool and made
+ available for re-use.
+
+ - [async] number reduceCapacity(n)
+
+ Removes up to `n` entries from the pool, with the caveat that it can
+ only remove currently-unused entries. It returns a Promise which
+ resolves to the number of entries actually removed.
+
+ - [async] boolean removeVfs()
+
+ Unregisters the opfs-sahpool VFS and removes its directory from OPFS
+ (which means that _all client content_ is removed). After calling
+ this, the VFS may no longer be used and there is no way to re-add it
+ aside from reloading the current JavaScript context.
+
+ Results are undefined if a database is currently in use with this
+ VFS.
+
+ The returned Promise resolves to true if it performed the removal
+ and false if the VFS was not installed.
+
+ If the VFS has a multi-level directory, e.g. "/foo/bar/baz", _only_
+ the bottom-most directory is removed because this VFS cannot know for
+ certain whether the higher-level directories contain data which
+ should be removed.
+
+ - [async] number reserveMinimumCapacity(min)
+
+ If the current capacity is less than `min`, the capacity is
+ increased to `min`, else this returns with no side effects. The
+ resulting Promise resolves to the new capacity.
+
+ - boolean unlink(filename)
+
+ If a virtual file exists with the given name, disassociates it from
+ the pool and returns true, else returns false without side
+ effects. Results are undefined if the file is currently in active
+ use.
+
+ - string vfsName
+
+ The SQLite VFS name under which this pool's VFS is registered.
+
+ - [async] void wipeFiles()
+
+ Clears all client-defined state of all SAHs and makes all of them
+ available for re-use by the pool. Results are undefined if any such
+ handles are currently in use, e.g. by an sqlite3 db.
+ */
+ sqlite3.installOpfsSAHPoolVfs = async function(options=Object.create(null)){
+ const vfsName = options.name || optionDefaults.name;
+ if(0 && 2===++instanceCounter){
+ throw new Error("Just testing rejection.");
+ }
+ if(initPromises[vfsName]){
+ //console.warn("Returning same OpfsSAHPool result",options,vfsName,initPromises[vfsName]);
+ return initPromises[vfsName];
+ }
+ if(!globalThis.FileSystemHandle ||
+ !globalThis.FileSystemDirectoryHandle ||
+ !globalThis.FileSystemFileHandle ||
+ !globalThis.FileSystemFileHandle.prototype.createSyncAccessHandle ||
+ !navigator?.storage?.getDirectory){
+ return (initPromises[vfsName] = Promise.reject(new Error("Missing required OPFS APIs.")));
+ }
+
+ /**
+ Maintenance reminder: the order of ASYNC ops in this function
+ is significant. We need to have them all chained at the very
+ end in order to be able to catch a race condition where
+ installOpfsSAHPoolVfs() is called twice in rapid succession,
+ e.g.:
+
+ installOpfsSAHPoolVfs().then(console.warn.bind(console));
+ installOpfsSAHPoolVfs().then(console.warn.bind(console));
+
+ If the timing of the async calls is not "just right" then that
+ second call can end up triggering the init a second time and chaos
+ ensues.
+ */
+ return initPromises[vfsName] = apiVersionCheck().then(async function(){
+ if(options.$testThrowInInit){
+ throw options.$testThrowInInit;
+ }
+ const thePool = new OpfsSAHPool(options);
+ return thePool.isReady.then(async()=>{
+ /** The poolUtil object will be the result of the
+ resolved Promise. */
+ const poolUtil = new OpfsSAHPoolUtil(thePool);
+ if(sqlite3.oo1){
+ const oo1 = sqlite3.oo1;
+ const theVfs = thePool.getVfs();
+ const OpfsSAHPoolDb = function(...args){
+ const opt = oo1.DB.dbCtorHelper.normalizeArgs(...args);
+ opt.vfs = theVfs.$zName;
+ oo1.DB.dbCtorHelper.call(this, opt);
+ };
+ OpfsSAHPoolDb.prototype = Object.create(oo1.DB.prototype);
+ // yes or no? OpfsSAHPoolDb.PoolUtil = poolUtil;
+ poolUtil.OpfsSAHPoolDb = OpfsSAHPoolDb;
+ oo1.DB.dbCtorHelper.setVfsPostOpenSql(
+ theVfs.pointer,
+ function(oo1Db, sqlite3){
+ sqlite3.capi.sqlite3_exec(oo1Db, [
+ /* See notes in sqlite3-vfs-opfs.js */
+ "pragma journal_mode=DELETE;",
+ "pragma cache_size=-16384;"
+ ], 0, 0, 0);
+ }
+ );
+ }/*extend sqlite3.oo1*/
+ thePool.log("VFS initialized.");
+ return poolUtil;
+ }).catch(async (e)=>{
+ await thePool.removeVfs().catch(()=>{});
+ return e;
+ });
+ }).catch((err)=>{
+ //error("rejecting promise:",err);
+ return initPromises[vfsName] = Promise.reject(err);
+ });
+ }/*installOpfsSAHPoolVfs()*/;
+}/*sqlite3ApiBootstrap.initializers*/);
+//#else
+/*
+ The OPFS SAH Pool VFS parts are elided from builds targeting
+ node.js.
+*/
+//#endif target=node