DEV Community

Anthony Jekanyika
Anthony Jekanyika

Posted on

Using SQLite (Fast SQL) with Drizzle ORM in Capacitor 8

In this post we will be looking into how to implement SQLite with Drizzle ORM in Capacitor 8 using Nuxt 4 as our Vite wrapper (You can use any other Vite framework). We will be using the Fast SQL for our Android and iOS implementation and SQLocal for the Web implementation. This post will also how to manage Drizzle migrations inside Capacitor 8.

Here is the demo repo of the full implementation to compare with.

Lets get started

Firstly, we need to create our Nuxt 4 app

npm create nuxt@latest capacitor-nuxt-fastsql-drizzle
Enter fullscreen mode Exit fullscreen mode
cd capacitor-nuxt-fastsql-drizzle
Enter fullscreen mode Exit fullscreen mode

Install core Capacitor and CLI

npm i @capacitor/core && npm i -D @capacitor/cli
Enter fullscreen mode Exit fullscreen mode

Install the plugins

npm i @capacitor/app @capacitor/keyboard @capacitor/splash-screen @capacitor/status-bar @capacitor/preferences
Enter fullscreen mode Exit fullscreen mode

Initialize Capacitor with our project details

npx cap init my-app com.example.myapp --web-dir .output/public
Enter fullscreen mode Exit fullscreen mode

Replace my-app with your app name and com.example.myapp with your app ID (reverse domain notation).

Update the capacitor.config.ts file with the following configurations

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.myapp',
  appName: 'my-app',
  webDir: '.output/public',
  plugins: {
    SplashScreen: {
      launchShowDuration: 2000,
      launchAutoHide: true,
      androidScaleType: 'CENTER_CROP',
      splashFullScreen: true,
      splashImmersive: true,
    },
    Keyboard: {
      resize: 'body',
      resizeOnFullScreen: true,
    },
    StatusBar: {
      overlaysWebView: false,
      style: 'dark',
      backgroundColor: '#ffffffff'
    },
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

Install native platforms

npm i @capacitor/ios @capacitor/android
Enter fullscreen mode Exit fullscreen mode

Add the native platform folders

npx cap add ios
npx cap add android
Enter fullscreen mode Exit fullscreen mode

Capacitor will create ios and android folders at the root of your project containing the native projects.

To build the Android project, you need Android Studio. For iOS, you need a Mac with Xcode.

Add these scripts to your package.json

{
  "scripts": {
    "dev": "nuxt dev",
    "build": "nuxt build",
    "generate": "nuxt generate",
    "preview": "nuxt preview",
    "mobile": "npm run generate && npx cap sync",
    "mobile:ios": "npm run mobile && npx cap open ios",
    "mobile:android": "npm run mobile && npx cap open android"
  }
}
Enter fullscreen mode Exit fullscreen mode

Lets test if we did the setup correctly
For Android

npm run mobile:android
Enter fullscreen mode Exit fullscreen mode

For iOS

npm run mobile:ios
Enter fullscreen mode Exit fullscreen mode

Build and run the app
Android Studio
In Android Studio, wait for the project to be ready, and then click on the “Run” button to deploy the app to a connected device or emulator.

Xcode
In Xcode, set up your signing account to deploy the app to a real device. If you haven’t done this before, Xcode will guide you through the process (note that you need to be enrolled in the Apple Developer Program). Once set up, click on the “Play” button to run the app on your connected device.

If you have been successful you should see this on your device. If not go over the steps again and try again.

Test

Setup

SQLocal makes it easy to run SQLite3 in the browser, backed by the Origin Private File System which provides high-performance read/write access to a SQLite database file stored on the user's device. We will be this for the web implementation of our application.

Install SQLocal, Fast SQL and Drizzle

npm i sqlocal drizzle-orm@1.0.0-beta.22 drizzle-kit@1.0.0-beta.22 @noble/hashes
Enter fullscreen mode Exit fullscreen mode

At the time of writing this blog v1.0.0-beta.22 is the latest version of Drizzle.

In order to persist data to the Origin Private File System, SQLocal relies on APIs that require cross-origin isolation, so we have to globally add the these headers to all the pages of our application:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Enter fullscreen mode Exit fullscreen mode

We will do this by updating our nuxt.config.ts file to the following:

import sqlocal from 'sqlocal/vite';

export default defineNuxtConfig({
  vite: {
    plugins: [
      sqlocal()
    ],
    server: {
      headers: {
        'Cross-Origin-Opener-Policy': 'same-origin',
        'Cross-Origin-Embedder-Policy': 'require-corp'
      }
    },
    optimizeDeps: {
      exclude: ['@sqlite.org/sqlite-wasm'],
    },
    assetsInclude: [
      "**/*.sql"
    ]
  }
})

Enter fullscreen mode Exit fullscreen mode

For Android and iOS we will be using Fast SQL a high-performance SQLite Database that locally hosts an HTTP server on your device to greatly increase the speed and throughput of our SQL queries.

Install Fast SQL and sync capacitor

npm i @capgo/capacitor-fast-sql
npx cap sync
Enter fullscreen mode Exit fullscreen mode

Add Fast SQL configurations to capacitor.config.ts file

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.example.myapp',
  appName: 'my-app',
  webDir: '.output/public',
  plugins: {
    SplashScreen: {
      launchShowDuration: 2000,
      launchAutoHide: true,
      androidScaleType: 'CENTER_CROP',
      splashFullScreen: true,
      splashImmersive: true,
    },
    Keyboard: {
      resize: 'body',
      resizeOnFullScreen: true,
    },
    StatusBar: {
      overlaysWebView: false,
      style: 'dark',
      backgroundColor: '#ffffffff'
    },
    CapacitorSQLite: {
      iosDatabaseLocation: 'Library/CapacitorDatabase',
      iosIsEncryption: true,
      iosKeychainPrefix: 'nuxt-sqlocal-capacitor',
      iosBiometric: {
        biometricAuth: false,
        biometricTitle : "Biometric login for capacitor sqlite"
      },
      androidIsEncryption: true,
      androidBiometric: {
        biometricAuth : false,
        biometricTitle : "Biometric login for capacitor sqlite",
        biometricSubTitle : "Log in using your biometric"
      },
      electronIsEncryption: true,
      electronWindowsLocation: "C:\\ProgramData\\CapacitorDatabases",
      electronMacLocation: "/Volumes/Development_Lacie/Development/Databases",
      electronLinuxLocation: "Databases"
    }
  }
};

export default config;
Enter fullscreen mode Exit fullscreen mode

Sync capacitor

npx cap sync
Enter fullscreen mode Exit fullscreen mode

Create the Drizzle Schema

Create the db/schema.ts file and add the following:

import { sqliteTable, int , text } from 'drizzle-orm/sqlite-core';

export const groceries = sqliteTable('groceries', {
    id: int('id').primaryKey({ autoIncrement: true }),
    name: text('name').notNull(),
    quantity: int('quantity').notNull()
});
Enter fullscreen mode Exit fullscreen mode

See Drizzle Documentation to see how to define your schema using the functions that Drizzle ORM provides.

Instantiate Drizzle

This will allow us to use only one instance of Drizzle in our application and to have it defined globally across our application without needing to import it.

Create the file app/utils/db.ts and copy add the following:

import { SQLocalDrizzle } from 'sqlocal/drizzle';
import { type SqliteRemoteDatabase, drizzle } from 'drizzle-orm/sqlite-proxy';
import { Capacitor } from '@capacitor/core';
import { FastSQL } from '@capgo/capacitor-fast-sql';

let db: SqliteRemoteDatabase<Record<string, never>, any>;
const DATABASE_NAME = "database";
if(Capacitor.getPlatform() === "web") {
    const { driver, batchDriver } = new SQLocalDrizzle('database.sqlite3');
    db = drizzle(driver, batchDriver);
} else {
    try {
        const conn = await FastSQL.connect({ database: DATABASE_NAME });

        const driver = async (sql: string, params: (string | number)[], method: 'get' | 'all' | 'values' | 'run') => {
            try {
                sql = sql.trim().replace(/[\r\n\t]+/gm, " ");

                const result = await conn.execute(sql, params);

                if(method === 'get') {
                    return { rows: result.rows.flatMap(row => Object.values(row)) }
                } else {
                    return { rows: result.rows.map(row => Object.values(row)) }
                }
            } catch (e: any) {
                alert('Error from sqlite proxy server: ' + e.response.data)
                return { rows: [] };
            }
        }

        const batchDriver = async (queries: { sql: string, params: any[], method: 'all' | 'run' | 'get' | 'values'}[]) => {
            try {
                const result = await conn.executeBatch([
                    ...queries.map(({ sql, params }) => ({
                        statement: sql,
                        params
                    }))
                ]);

                return result
            } catch (e: any) {
                alert('Error from sqlite proxy server: ' + e);
                throw e;
            }
        }

        db = drizzle(driver, batchDriver);
    } catch(error) {
        alert(error)
    }
}

export { db }
Enter fullscreen mode Exit fullscreen mode

Add Pages

Replace the contents of your app/app.vue file with this

<template>
  <NuxtPage />
</template>
Enter fullscreen mode Exit fullscreen mode

Create the folder app/pages and then add the file app/pages/index.vue and add the following contents

<template>
    <main>
        <h2>Create Groceries</h2>
        <form @submit.prevent="createGroceries">
            <div>
                <input v-model="name" placeholder="Enter the name" required />
            </div>
            <div>
                <input v-model="quantity" type="number" placeholder="Enter the quantity" validate required />
            </div>
            <button type="submit">Submit</button>
        </form>

        <h2>Grocery List ({{ grocery_list.length }})</h2>
        <div>
            <input v-model="search_query" placeholder="Search" validate required />
        </div>
        <table>
            <thead>
                <tr>
                    <th>ID</th>
                    <th>Name</th>
                    <th>Quantity</th>
                </tr>
            </thead>
            <tbody>
                <tr v-for="{ id, name, quantity} of grocery_list">
                    <td>{{ id }}</td>
                    <td>{{ name }}</td>
                    <td>{{ quantity }}</td>
                </tr>
            </tbody>
        </table>
    </main>
</template>

<script setup lang="ts">
    import { like } from 'drizzle-orm';
    import { groceries } from "~~/db/schema";
    import { migrate } from '~~/db/migrator'

    const name = ref<string>("");
    const quantity = ref<number>(0);
    const search_query = ref<string>();
    const grocery_list = ref<typeof groceries.$inferInsert[]>([]);

    watch(search_query, (value) => search(value))

    onMounted(async () => {
        await migrate()
        grocery_list.value = await db.select().from(groceries)
    })

    const createGroceries = async () => {
        try {
            await db.insert(groceries).values({
                name: name.value,
                quantity: quantity.value
            })

            grocery_list.value = await db.select({
                id: groceries.id,
                name: groceries.name,
                quantity: groceries.quantity
            })
            .from(groceries)

            // Clear the form
            name.value = "";
            quantity.value = 0;
        } catch (error) {
            alert(error)
        }
    }

    const search = async (query: string | undefined) => {
        if(search_query.value) {
            grocery_list.value = await db.select()
                .from(groceries)
                .where(like(groceries.name, `%${ query }%`))
        } else {
            grocery_list.value = await db.select().from(groceries)
        }
    }
</script>
Enter fullscreen mode Exit fullscreen mode

Drizzle Migrations

When ever you change the Drizzle schema db/schema.ts. You need to create the migration files. You can do this by first creating the drizzle.config.ts file in your project's root directory and then adding the following:

import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  out: './db/migrations',
  schema: './db/schema.ts',
  dialect: 'sqlite'
})
Enter fullscreen mode Exit fullscreen mode

Update the scripts in your package.json to the following:

{
  "scripts": {
    "dev": "nuxt dev",
    "build": "nuxt build",
    "generate": "nuxt generate",
    "preview": "nuxt preview",
    "mobile": "npm run generate && npx cap sync",
    "mobile:ios": "npm run mobile && npx cap open ios",
    "mobile:android": "npm run mobile && npx cap open android",
    "drizzle:generate": "drizzle-kit generate --dialect=sqlite --driver=durable-sqlite --schema=./db/schema.ts --out=./db/migrations"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then to generate the migration files you need to run the following:

npm run drizzle:generate
Enter fullscreen mode Exit fullscreen mode

This will create SQL migration files in the db/migrations directory.

Drizzle Client-Side Migration

Drizzle does not have an in-built system to track migration files on the client-side of your add as drizzle-kit, the Drizzle migration management CLI tool, doesn't run on the client-side. We are going to have to implement our own custom solution to apply the migration files and keep track of the migrations that have already been applied in a _drizzle_migrations table. To do this we need to create the db/migrator.ts file and add the following:

import { sha256 } from "@noble/hashes/sha2.js";
import { Buffer } from "buffer";
import { sql } from "drizzle-orm";
import { db } from "../app/utils/db";
import migration_summary from "./migrations/migrations.js"

export const migrate = async () => {
    // Create the migration table to keep track of all the applied migrations
    const MIGRATIONS_TABLE_NAME = sql.identifier("_drizzle_migrations");

    await db.run(
        sql`
          CREATE TABLE IF NOT EXISTS ${ MIGRATIONS_TABLE_NAME } (
            id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
            name TEXT NOT NULL,
            hash TEXT NOT NULL,
            created_at INTEGER NOT NULL
          );
        `,
    );

    type AppliedMigration = {
        id: number,
        name: string,
        hash: string,
        created_at: number
    }

    const appliedMigrations = (await db.all(
        sql`SELECT * FROM ${ MIGRATIONS_TABLE_NAME };`,
    )) as AppliedMigration[];

    await db.transaction(async (tx) => {
        for(let migration_name of Object.keys(migration_summary.migrations)) {
            // Get migration file a raw text
            let migration_sql = (await import(`./migrations/${ migration_name }/migration.sql?raw`)).default;

            migration_sql = migration_sql.trim().replace(/[\r\n\t]+/gm, " ");

            // Generate a hash of the migration file
            const hash = Buffer.from(sha256(Buffer.from(migration_sql))).toString("hex")

            // Check if the migration file was applied to the SQLite DB  
            if (appliedMigrations.some((applied_migration) => applied_migration?.hash === hash)) continue;

            // Now run the migration on to the SQLite DB
            try {
                await tx.run(migration_sql)
            } catch(error) {
                console.error(error)
            }

            // Log this migration as applied onto the SQLite DB
            await tx.run(
                sql`
                    INSERT INTO ${ MIGRATIONS_TABLE_NAME } ("name", "hash", "created_at") VALUES (
                    ${ sql.raw(`'${ migration_name }'`) },
                    ${ sql.raw(`'${ hash }'`) },
                    ${ sql.raw(`${ Date.now() }`) }
                    );
                `,
            );
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Deploy

Now that we are done creating our app, let us generate the static files for our app and deploy them to either Android Studio or Xcode

For Android Studio

npm run mobile:android
Enter fullscreen mode Exit fullscreen mode

For Xcode

npm run mobile:ios
Enter fullscreen mode Exit fullscreen mode

If you have been successful, you should have an app that you can use to store grocery lists like this:

Grocery List Appp

Checkout the demo repo with everything that we have discussed above.

Feel free to ask me any questions in the comments section below and I will try to respond to them as quickly as I can. Thank you guys and have a great day.

Reference List

  1. Is SQLite WASM the future of offline-first Vue apps?
  2. SQLocal Drizzle Setup
  3. Convert Your Nuxt App to iOS & Android with Capacitor 8
  4. Drizzle Proxy
  5. Using Durable Objects to generate drizzle migration files
  6. Client-Side Drizzle Migrations
  7. Importing assets as string in Vite

Top comments (0)