As any developer, who doesn’t just love it when a product that has grown over the years suddenly needs to target a new platform (e.g. operating system) because some customer demands changed, some dependency broke or some other totally unexpected thing called “progress” happened?
Fortunately, there are some approachs to cross-platform development and if one expects such a change of direction, one can early on adopt a suitable runtime environment such as Apache Cordova or Capacitor/Ionic or similar, who all promise you a Write-Once-Run-Anywhere experience, decoupling the application logic from the lower-level OS interactions.
Unfortunately though, this promise is a total lie and usually, after starting such a totally platform-agnostic project, really soon you will want to use a dependency that will only work for one platform and then your options are limited.
One such example is a Cordova project we are currently moving from Android to iOS, and in that process also redesigning a nice, modern frontend to replace a very outdated (read: unmaintainable) Vanilla JS application. So now we have set it up smoothly (React + Vite + Typescript – you name it!), so technically we do not need anything iOS-specific yet, so we can work on our redesign in a pure-browser environment with hot reloading and the likes – life is good!
Then comes the realization that our application is quite data heavy and uses an on-device SQL database to persist its data, and we don’t have that in the browser – so, life turned bad.
What to do? There had been a client-side WebSQL database specification once, but this was unofficial and never fully implemented, abandoned in 2010, still present in Chrome but they are even live announcing how they are removing it, so this is not the future-proof way to go.
We crave a smooth flow of development.
- It is not an option to re-build the app at every change.
- It is not an option to have the production system use its SQLite DB and the development environment to use a totally different one like IndexedDB – certain SQLite queries are too ingrained in our application.
- It’s only probably an option to use an experimental technology like absurd-sql, which aims to fill in that gap but then again needs advanced API features like Web Workers, SharedArrayBuffer, Atomics API which we wouldn’t require else
- It is possible to use in-memory SQLite via sql.js but for persistence, it wasn’t instantly obvious to me how to couple that with the partially supported Origin Private File System API
So after all, this is the easiest solution that still gave me most of my developer smoothness back: Use sql.js in memory and for development, display two nice buttons on the UI which let me download the whole DB and upload one from file again. This is the sketch:
We create a CombinedDatabase class which, depending on the environment, can hand out such a database in a Singleton-like manner
class CombinedDatabase {
// This is the Singleton-part
private static instance: CombinedDatabase;
public static get = async (): Promise<CombinedDatabase> => {
if (!this.instance) {
const {db, type} = await this.createDatabase();
this.instance = new CombinedDatabase(db, type);
}
return this.instance;
};
private static createDatabase = async () => {
if (inProductionEnvironment()) {
return {
db: createCordovaSqliteInstance(),
type: "CordovaSqlite"
};
} else {
const sqlWasmUrl = (await import("../assets/sql-wasm.wasm?url")).default;
// we extend the window object for reasons I tell you below
window.sqlJs = await initSqlJs({locateFile: () => sqlWasmUrl});
const db = new window.sqlJs.Database();
return {db, type: "InMemory"};
}
}
// This is the actual flesh, i.e. a switch of which API to use
private readonly type: string;
private cordovaSqliteDb: SQLitePlugin.Database | null = null;
private inMemorySqlJsDb: SqlJsDatabase | null = null;
private constructor(db: SQLitePlugin.Database | SqlJsDatabase, type: string) {
this.type = type;
switch(type) {
case "CordovaSqlite":
this.cordovaSqliteDb = db as SQLitePlugin.Database;
break;
case "InMemory":
this.inMemorySqlJsDb = db as SqlJsDatabase;
break;
default:
throw Error("Invalid CombinedDatabase type: " + type);
}
}
// ... and then there are some methods
}
(This is simplified – in actual, type
is an enum for me , and there’s also error handling, but you know – not the point here).
This structure is nice, because you can now implement low-level methods like some executeQuery(...)
etc. which just decide depending on the type
, which of the private DB instances it can address, and even if they work differently, return a unified response format.
The rest of our application does not know anything about any Cordova-SQLite-dependency, or sql.js, or whatever. Life is good again.
So How do Import / Export work?
I gave the CombinedDatabase some interfacing methods, similar to
public async export() {
switch (this.type) {
case "CordovaSqlite":
throw Error("Not implemented for cordova-sqlite database");
case "InMemorySqlJs":
return this.inMemorySqlJsDb!.export();
default:
throw Error("DB not initialized, cannot export.");
}
}
public async import(binaryData: Uint8Array) {
if (this.type !== CombinedDatabaseType.InMemorySqlJs) {
throw Error("DB import only implemented for the in-memory/sql.js database, this is a DEVELOPMENT feature!");
}
await this.close();
this.inMemorySqlJsDb = new window.sqlJs.Database(binaryData);
}
This is also the reason why I monkey-patched the window object earlier, so I still have this API around outside the Singleton instantiation (createDatabase). Yes, this is a global variable and a kind of hack, but imo is what can safely be done inside the Browser within some good measure.
Remember, in Typescript you need to declare this e.g. in some global.d.ts file
import {SqlJsStatic} from "sql.js";
declare global {
interface Window {
sqlJs?: SqlJsStatic
}
}
Or go around the Window interface by casting (window as any).sqlJs – you decide what you prefer.
Anyway, the export() functionality can then be used quite handily, it returns the in-memory database as a binary array and you can make the browser download that via a Blob URL:
api.db.export().then((array: Uint8Array) => {
const blob = new Blob([array], {type: "application/x-sqlite3"});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `bonpland${Date.now()}.db`;
link.target = "_blank";
link.click();
});
And similarly, you can use import() by reading a Uint8Array from a temporary <input type="file">
element with a FileReader() (somewhat common solution, but just comment below if you want the details).
To be exact, I don’t even use the import() button anymore because I pass my development DB as an asset to the dev server. This is nice (and only takes a few seconds on hot reloading because our DB is like 50 MB in size), but somewhat Vite-specific, which is why I will postpone this topic to some later blog time.