LokiJS Disk Storage Adapter for NeutralinoJS
One of the choices I’ve had to make in building my SamplesOrganizer app is what database to use. I wanted an in-memory database that could handle a large amount of data, and persist it to disk without adding tens or hundreds of megabytes.
Enter LokiJS. Loki is a document database (like Mongo) that simply stores your data as a flat JSON file on disk. Per its documentation it’s good and speedy for database files up to 100 megabytes. Given my average record size, it should be performant up to about 80,000 sounds, maybe more.
I usedLokiJS for a project back in 2016 when it was being actively developed. Now, in 2024, it isn’t actively developed anymore, but it reached a reasonable level of maturity and it’s simple and non-bloating, helping my keep the overall app down to a minimal size.
Unfortunately, because Neutralino isn’t running Node like Electron does, the LokiJS Node.fs-based persistence adapter for local storage doesn’t work, and based on my experiments, the LokiJS persistence adapter that uses IndexedDb runs into Apple privacy protections that delete IndexedDb storage data after a week of not using the app. Thus I had to create my own adapter.
I’ve sort of done this before
This is not the first time I’ve created an adapter to persist data between sessions. When I was an evangelist with Amazon Alexa, I created a local persistence adapter to do essentially the same thing for Alexa skill session data, a mimic of the AWS S3 persistence adapter that is built in, but to store your data locally when developing and debugging locally.
Sadly, I put it in the Alexa Cookbook repository, which I championed decommissioning before I left Alexa 3.5 years ago. It was decommissioned and archived for a while. Now it’s not available at all. Still, if you’re the type who says “pics or it didn’t happen,” I demonstrated it on a Alexa Developers India presentation (which I had to be up REALLY early on a Saturday morning to do).
The “LokiJS Data Persistence Adapter for Neutralino” Code
A couple of notes first. The data_path
variable is a constant in my app that is determined outside the class. It holds the result of a utility function that gets the library path from Neutralino, adds a directory name for the app’s data, and creates the directory if it hasn’t been created. The reason for the constant is that other app data is stored in that directory, so to prevent calling that function many times, it’s called once in initialization and the constant is set.
Also UTILS.error_modal
is a utility function in my code, used by multiple functions to display error messages as modal pop-ups.
Here’s the code for the function that establishes the data_path
and ensures it exists.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
UTILS.getDataDir = async () => { //use the OS API to get the native data path //for Mac, for example, it's /User/[username]/Library/Application Support let lib_path = await Neutralino.os.getPath("data"); let app_path = lib_path + "/SamplesOrganizer/"; let dp_temp = ''; try{ let stats = await Neutralino.filesystem.getStats(app_path); if(stats) dp_temp = app_path; } catch (e) { try{ let t = await Neutralino.filesystem.createDirectory(app_path); dp_temp = app_path; } catch(e) { UTILS.error_modal("The data directory for this app cannot be created. The error given was: ", e); throw("Data Dir could not be created!") } } return dp_temp; } |
Of course, you’d replace SamplesOrganizer
with the name you want to use for your app’s data. This said, please use something that will be meaningful to the user. If they browse this folder, they shouldn’t look at your directory and wonder what app’s data it’s storing.
Now here’s the code for the adapter.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
class LokiFSAdapter { loadDatabase = async (dbname, callback) => { // using dbname, load the database from wherever your adapter expects it // create the full path let dbfile = data_path + dbname; let success; // check if dbfile exists / create if not let t = await this.checkDbFile(dbfile); try{ var serializedDb = await Neutralino.filesystem.readFile(dbfile); success = true; } catch { success = false; } if (success) { callback(serializedDb); } else { callback(new Error("There was a problem loading the database")); } } saveDatabase = async (dbname, dbstring, callback) => { // store the database, for MYDB. example to localstorage let dbfile = data_path + dbname; let success; // check if dbfile exists / create if not let t = await this.checkDbFile(dbfile); try{ var serializedDb = Neutralino.filesystem.writeFile(dbfile, dbstring); success = true; } catch { success = false; } if (success) { callback(null); } else { callback(new Error("An error was encountered saving the " + dbname + " database.")); } } async checkDbFile (dbfile) { try{ let stats = await Neutralino.filesystem.getStats(dbfile); if(stats) return true; } catch (e) { try{ await Neutralino.filesystem.writeFile(dbfile, ''); return true; } catch { UTILS.errorModal("The database file is unwriteable. The error given was: ", dbfile); throw("Database could not be created!") } } } } |
In the long run, I may swap out a more actively maintained database engine or add a plugin architecture for a BYO database option. For now, this database is functional and extremely lightweight, helping me keep my executable size under 4 megabytes in most instances (Mac Universal Binary is larger).
As I’ve said, I plan to open source the code after a private beta period to work out some of the kinks and allow me to refactor my code for easier readability/maintainability (since I get tunnel vision on an objective and coding standards can give way to “Git ‘er Done“). But this bit is, I guess, ready for release. So take it and go.