lars
webmobiledatenbankendevopsarchitektur
hello (at) larskoelpin.de

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.