Persistente Daten im Browser mit SQLite und LocalStorage
January 22, 2023
typescriptweb
Offlinefähige Single-Page-Applikationen und Progressive WebApps brauchen häufig eine Datenhaltung. Standardisiert stehen dafür zur Zeit localStorage, sessionStorage oder indexedDB zur Verfügung. Während im localStorage key-value Paare abgelegt werden können, ist indexedDB auch für andere Daten-Abfragen zugänglich. Das Problem dabei: Alle APIs sind spezielle Datenbanken — wichtige Mengen-Operationen sind schwer abbildbar. Eine Abhilfe kann SQLite schaffen.
SQLite ist eine Datenbank, die sich direkt in Applikationen einbinden lässt. Ein extra Server ist nicht nötig. Sie kann dabei im Arbeitsspeicher oder wichtiger, persistent, Daten sichern. Während Daten im Arbeitsspeicher zwar SQL-Möglichkeiten ermöglichen, ist eine Datenbank die Daten nicht persistent speichert weniger sinnig — Schließlich inkludiert ACID (Atomic, Consistency, Isolation, Durability) auch die Durabilty.
Leider besitzt der Browser zur Zeit (Stand 2023) keine wirklich produktionsreife Schnittstelle, um auf das Dateisystem zuzugreifen. SQLite bietet deshalb eine andere Möglichkeit an, Daten-Seiten im Browser zu Speichern: Mithilfe des LocalStorage. Und das schau ich mir an.
Um SQLite für den Browser bereit zu machen, muss der existierende C-Code Web-fähig gemacht werden. Das funktioniert zum Beispiel mit WebAssembly.
Dazu muss, wie in der Dokumentation beschrieben emscripten
installiert werden.
Durch ein folgendes ./configure && cd ext/wasm && make
kann die WebAssembly binary dann kompiliert werden.
Im generierten Verzeichnis jswasm
liegen dann die sqlite3.wasm
und sqlite3.js
, welche die SQLite Browser-Distribution wiederspiegeln und von der eigenen Applikation mit ausgeliefert werden.
Diese müssen im eigenen Projekt eingefügt werden. Die Ordnerstruktur der Applikation kann dann wie folgt aussehen.
sqlite-helloworld
┣ app.js
┣ index.html
┣ sqlite3.js
┗ sqlite3.wasm
Die index.html lädt dabei sowohl sqlite3.js
sowie app.js
, wo der User-Code drin liegt.
<body>
<script src="sqlite3.js"></script>
<script src="app.js"></script>
</body>
In der Datei app.js
verweilt der Nutzer-Code.
Dieser enthält den Code, der die API von SQLite nutzen soll.
const setup = async () => {
const sqlite = await sqlite3InitModule();
const oo = sqlite.oo1;
const capi = sqlite.capi;
if (!capi.sqlite3_vfs_find("kvvfs")) {
console.error("This build is not kvvfs-capable.");
return;
}
const db = new oo.JsStorageDb("local");
};
Im Nutzercode erzeugen wir die SQLite3 Instanz.
Die benötigte Funktion sqlite3InitModule
wird dabei aus sqlite3.js
an das window
-Objekt gehangen.
Deshalb ist es wichtig, dass sqlite3.js
vor app.js
geladen wird.
Die capi
wie oo1
sind dabei APIs, die von sqlite3 bereitgestellt werden.
Wir testen zunächst, ob das kvvfs
unterstützt wird.
Das kvvfs
ist der Treiber, der die Daten in das localStorage
, die genutzte Persistenz-API, schreibt.
Folglich kann mittels oo.JsStorageDb("local")
ein Datenbank-Objekt erzeugt werden, dessen Daten in den localStorage
geschrieben werden.
Ist der Datenbank-Client erzeugt, kann mithilfe dessen eine konkrete Datenbank erzeugt werden.
Dazu dient die .exec
Schnittstelle (https://www.sqlite.org/c3ref/exec.html), um das entsprechende SQL ausführen zu können.
Die API dessen kann aus der C-Dokumentation übernommen werden und muss ggf. in JavaScript Syntax übersetzt werden.
Demnach kann durch ein
db.exec({
sql: "create table if not exists persons(name TEXT, age INTEGER);",
});
eine Tabelle persons
erzeugt werden, dessen Spalten der Tabelle name
und age
enthalten.
Natürlich interessieren mich die Einschränkungen — Schließlich ist localStorage nicht dafür bekannt besonders viel Speicherplatz bereitzustellen (Limit je nach Implementierung bis zu 2.5MB bis zu 25 MB).
Und diese sind schnell erreicht.
Also mal schauen, wie sich der Chrome Browser verhält.
Dazu habe ich eine API implementiert und an das window
-Objekte gehangen, um diese aus den Chrome-DevTools anzusteuern.
window.deleteDb = () => {
db.clearStorage();
};
window.createDb = () => {
db.exec({
sql: "CREATE TABLE PERSONS(name TEXT, age INTEGER)"
});
};
window.seed = () => {
for (let i = 0; i < 100000; i++) {
db.exec({
sql: "INSERT INTO persons(name, age) VALUES ('lars', 12)",
});
console.log("inserted", i, "current size:" , db.storageSize() / 1024 / 1024, "mbytes");
}
};
window.allPersons = () => {
db.exec({
sql: "select COUNT(*) from persons",
rowMode: 0,
callback: (v) => console.log(v),
});
};
Die Funktionen bieten dazu zum Testen das Anlegen, Löschen, Schreiben sowie Lesen der Daten an.
Durch ein Ausführen von
createDb();
seed();
in der JavaScript Konsole, lässt sich die Datenbank befüllen. In diesem Fall gibt es nach genau 187298 Iterationen (natürlich stark abhängig von der Tabelle und dessen Daten) den erwarteten Fehler.
sqlite3.js:12495 kvstorageWrite() DOMException: Failed to execute 'setItem' on 'Storage': Setting the value of 'kvvfs-local-321' exceeded the quota.
Schluss ist demnach bei Chrome — wie zu erwarten — bei ungefähr 10Mb. Jede weitere Operation wird mit einem disk I/O error beantwortet.
sqlite3.js:12558 Uncaught SQLite3Error: sqlite3 result code 10: disk I/O error
Fazit
Die offizielle SQLite Distribution lässt sich problemlos für das Web/Webassemly Kompilieren und mit einem Storage-Backend versehen. Für kleine (lokale) Datenmengen ist das localStorage backend sicherlich geeignet — denn mal ehrlich: wie viele (nicht Binäre) Daten soll ein einzelner Nutzer denn generieren? Wir erhalten aber die Möglichkeit SQL und dessen Operationen zu nutzen. Und das ist viel Wert — Views, Tabellen, Joins und sogar JSON-Operationen sind bereits auf Daten-Ebene verfügbar.
Das LocalStorage Backend ist leider stark vom Browser eingeschränkt — und damit sinkt auch die Anzahl potentieller Anwendungsfälle. Projekte wie absurdSQL nutzen IndexedDB als Speicher-API und bieten damit potentiell unendlich viel Speicher. Leider ist das Projekt weitesgehend tot. Bleibt also nur zu hoffen, dass das Origin Private File System bald Anklang findet damit auch diese Limitation verschwindet.