In my previous react native sqlite series, I've shown how you can use different storage providers for your app, and most importantly how you can integrate expo-sqlite, test it and make it work from the web.

Bad part about it is that SQLite on the web (SQL.js) won't be persisted between sessions. It is possible though. Requires a bit of work and the help of indexedDB.

Expo SQLite on the web

I've made it possible with SQL.js, expo platform specific modules and applying the adapter pattern. There's a post about it. Here's a partial snippet how it looks like, and check out the full code at github.

import { BindParams } from 'sql.js';
import initSqlJs from 'sql.js';
export type SQLiteDatabase = any;
const sqlPromise = initSqlJs({
  locateFile: (file) => `https://sql.js.org/dist/${file}`,
});

export async function openDatabaseAsync(databaseName: string, options?: any): Promise<SQLiteDatabase> {
  const SQL = await sqlPromise;

  const db = new SQL.Database();
  return {
    execAsync: async (source: string): Promise<void> => {
      db.exec(source);
    },
    runAsync: async (source: string, params: BindParams): Promise<any> => {
      db.run(source, params);
      return {
        lastInsertRowId: db.exec('SELECT last_insert_rowid()')[0].values[0][0] as number,
        changes: db.getRowsModified(),
      };
    },
  };
}

What's for expo-sqlite is in the *.ts module, and the exact same interface is applied in the *.web.ts.

SQL.js doesn't persist between sessions

It operates in-memory by default, leading to data loss between sessions. And that's ok, there's a workaround for this. I use indexedDB for that.

My utility looks like:

import { openDB } from 'idb';
import { Database } from 'sql.js';

const DB_NAME = 'testmigrationsv1';
const STORE_NAME = 'databases';
const KEY_NAME = 'sqlite-data';

const idbPromise = openDB(DB_NAME, 1, {
  upgrade(db) {
    if (!db.objectStoreNames.contains(STORE_NAME)) {
      db.createObjectStore(STORE_NAME);
    }
  },
});
export async function loadFromIndexedDB(): Promise<Uint8Array | null> {
  const idb = await idbPromise;
  return await idb.get(STORE_NAME, KEY_NAME);
}
export async function saveToIndexedDB(db: Database) {
  const dbData = db.export();
  const idb = await idbPromise;
  await idb.put(STORE_NAME, dbData, KEY_NAME);
}

Now, whenever an execAsync or runAsync is called, it also calls saveToIndexedDb.

And when I refresh, SQLite is created from IndexedDB binary data.

export async function openDatabaseAsync(databaseName: string, options?: any): Promise<SQLiteDatabase> {
  const SQL = await sqlPromise;
  const savedData = await loadFromIndexedDB();
  const db = savedData ? new SQL.Database(savedData) : new SQL.Database();

  return {
    execAsync: async (source: string): Promise<void> => {
      db.exec(source);
      void saveToIndexedDB(db);
    },
  }
}

I don't like the db.export to be called every single time when there's an insert, update or delete, but that's what I've come up with so far. There will be a performance hit once the database gets big. Also, I'm not sure if IndexedDB will hit a limitation once the binary export gets too big.

If a database has frequent writes, maybe wrapping saveToIndexedDb with debounce logic could help a bit.

So far, I've used this solution for a web version of my ListDuo app for tracking groceries.

GitHub for demo: https://github.com/amarjanica/react-native-sqlite-expo-demo