Langsung ke konten utama

Cara Menggunakan Drizzle ORM dengan Capacitor dan SQLite

Set up Drizzle ORM dengan Capacitor dan SQLite: driver sqlite-proxy yang berfungsi, schema bertipe, query relasional, transaksi, dan migrasi Drizzle Kit in-app.

Logo Capacitor untuk panduan menggunakan Drizzle ORM dengan Capacitor dan SQLite

Drizzle ORM berjalan di aplikasi Capacitor melalui sqlite-proxy driver: Anda beri Drizzle satu fungsi async yang mengeksekusi SQL pada plugin SQLite native dan mengembalikan baris sebagai array, lalu Drizzle menangani typed query builder, relational queries, dan transaksi di atasnya. Panduan ini membangun fungsi itu untuk @capacitor-community/sqlite, menunjukkan cara menyertakan migrasi Drizzle Kit di dalam app bundle, serta daftar edge case yang dapat merusak hasil secara diam-diam jika terlewatkan.

Semua di bawah ini telah diuji dengan drizzle-orm 0.45 dan drizzle-kit 0.31 pada Capacitor 8.

Kenapa pakai Drizzle di database mobile

  • Schema dalam TypeScript. Tabel adalah objek TypeScript biasa. Type untuk select dan insert di-infer, tanpa langkah code generation.
  • API berbasis SQL. db.select().from(users).where(eq(users.id, 1)) dipetakan satu-ke-satu ke SQL, jadi Anda bisa memahami apa yang jalan di perangkat.
  • Migrasi digenerate dari schema. Drizzle Kit melakukan diff pada schema Anda dan menulis file migrasi SQL.
  • Tanpa decorator. Berfungsi dengan Vite dan esbuild tanpa flag TypeScript tambahan.

Trade-off-nya: Drizzle tidak memiliki driver Capacitor di paketnya, jadi Anda menulis bridge-nya sendiri. Sekitar 30 baris.

Memilih plugin SQLite

Driver sqlite-proxy Drizzle memetakan baris hasil berdasarkan posisi: untuk all dan values ia mengharapkan array berisi array, untuk get satu array, dalam urutan yang sama dengan kolom pada hasil generate SELECT. Plugin Capacitor SQLite mengembalikan objek dengan key berdasarkan nama kolom, sehingga driver harus mengubah setiap objek kembali menjadi array. Itu hanya berfungsi jika key objek tetap mengikuti urutan kolom.

Plugin Format baris Urutan kolom tetap terjaga Cocok untuk Drizzle proxy
@capacitor-community/sqlite Object Ya. Di iOS, plugin mengirimkan ios_columns daftar dan layer JS menyusun ulang setiap baris sesuai urutan kolom Bagus
@capgo/capacitor-fast-sql Object Tidak dijamin di iOS, baris diserialisasi dari Swift dictionary Gunakan Kysely saja

Itulah alasan panduan ini memakai plugin komunitas. Jika Anda sudah pakai Fast SQL, atau ingin jalur data yang lebih cepat, Kysely membaca baris berdasarkan nama kolom dan langsung bekerja dengannya. Untuk gambaran umum kedua plugin, lihat Cara menggunakan SQLite di aplikasi Capacitor.

Instal

bun add drizzle-orm @capacitor-community/sqlite
bun add -d drizzle-kit
bunx cap sync

@capacitor-community/sqlite mem-bundle SQLCipher di iOS dan Android, jadi aplikasi Anda sudah menyertakan enkripsi code meskipun Anda tidak mengenkripsi apa pun. Ingat hal ini untuk pertanyaan kepatuhan ekspor App Store.

Definisikan skema

// src/db/schema.ts
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
});

export const posts = sqliteTable(
  'posts',
  {
    id: integer('id').primaryKey({ autoIncrement: true }),
    authorId: integer('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    title: text('title').notNull(),
    published: integer('published', { mode: 'boolean' }).notNull().default(false),
  },
  (t) => [index('idx_posts_author').on(t.authorId)],
);

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));

Beberapa detail penting di mobile:

  • mode: 'timestamp' menyimpan detik sejak epoch sebagai integer dan memberi Anda Date objek di TypeScript.
  • $defaultFn(() => new Date()) mengatur default di JavaScript. Default SQL seperti unixepoch() membutuhkan SQLite 3.38 atau lebih baru, yang tidak dimiliki oleh SQLite sistem Android versi lama.
  • mode: 'boolean' menyimpan 1/0. Drizzle mengkonversi keduanya.
  • relations() adalah yang menggerakkan db.query.* dengan with: {...}. Foreign key saja tidak cukup untuk query relasional.

Open the connection

Plugin komunitas ini menyimpan registry koneksi. Saat live reload atau hot module replacement, code Anda bisa berjalan dua kali, jadi gunakan kembali koneksi yang ada daripada membuat koneksi baru.

// src/db/connection.ts
import {
  CapacitorSQLite,
  SQLiteConnection,
  type SQLiteDBConnection,
} from '@capacitor-community/sqlite';

const sqlite = new SQLiteConnection(CapacitorSQLite);

export async function openConnection(name: string): Promise<SQLiteDBConnection> {
  const consistent = (await sqlite.checkConnectionsConsistency()).result;
  const exists = (await sqlite.isConnection(name, false)).result;

  const conn =
    consistent && exists
      ? await sqlite.retrieveConnection(name, false)
      : await sqlite.createConnection(name, false, 'no-encryption', 1, false);

  await conn.open();
  await conn.execute('PRAGMA foreign_keys = ON', false);
  return conn;
}

Plugin ini menambahkan SQLite.db ke nama file, jadi app menjadi appSQLite.db di disk. Pada web, plugin membutuhkan jeep-sqlite elemen kustom dan sqlite.initWebStore() sebelum koneksi pertama. Ikuti setup web plugin jika Anda menargetkan browser.

Driver sqlite-proxy

Ini inti dari integrasinya. Drizzle memanggil fungsi dengan SQL, parameter, dan method:

  • run: tidak ada baris yang diperlukan (INSERT, UPDATE, DELETE, DDL, kontrol transaksi).
  • all: setiap baris, sebagai array.
  • get: baris pertama, sebagai array, atau undefined.
  • values: setiap baris, sebagai array (digunakan oleh raw values() panggilan dan internal).
// src/db/client.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';
import { drizzle } from 'drizzle-orm/sqlite-proxy';
import * as schema from './schema';

const READ = /^\s*(select|pragma|with|explain)\b/i;
const TX_CONTROL = /^\s*(begin|commit|rollback|savepoint|release)\b/i;

export function createDb(conn: SQLiteDBConnection) {
  return drizzle(
    async (sql, params, method) => {
      // Transaction control: no params, no rows, no implicit wrapping
      if (TX_CONTROL.test(sql)) {
        await conn.execute(sql, false);
        return { rows: [] };
      }

      let rows: Record<string, unknown>[];
      if (READ.test(sql)) {
        rows = (await conn.query(sql, params)).values ?? [];
      } else {
        // 'all' returns rows for INSERT/UPDATE/DELETE ... RETURNING
        const res = await conn.run(sql, params, false, method === 'run' ? 'no' : 'all');
        rows = res.changes?.values ?? [];
      }

      if (method === 'run') return { rows: [] };

      // Drizzle maps by position. The plugin keeps column order in each object.
      const arrays = rows.map((row) => Object.values(row));
      return { rows: method === 'get' ? (arrays[0] as any) : arrays };
    },
    { schema },
  );
}

export type AppDb = ReturnType<typeof createDb>;

Mengapa setiap cabang ada:

  • TX_CONTROL: Drizzle mengimplementasikan db.transaction() dengan mengirim begin, commit, rollback, dan savepoint/release untuk transaksi bersarang. Mereka melewati execute(sql, false), jalur yang sama yang digunakan driver resmi Capacitor TypeORM untuk BEGIN/COMMIT.
  • false sebagai argumen ketiga untuk run(): default plugin membungkus setiap statement dalam transaksinya sendiri, yang bertentangan dengan eksplisit Drizzle begin.
  • returnMode 'all': membuat .returning() berfungsi untuk insert, update, dan delete.
  • Object.values(row): mengubah baris berkey kembali menjadi array posisional.

Saya menjalankan fungsi persis ini terhadap SQLite in-memory yang mengembalikan baris seperti yang dilakukan plugin. Insert dengan .returning(), select terfilter, .get(), query relasional, transaksi, dan rollback saat error dilempar semuanya memberikan hasil yang benar.

Inisialisasi sekali saja

// src/db/index.ts
import { openConnection } from './connection';
import { createDb, type AppDb } from './client';
import { runMigrations } from './migrate';
import bundle from '../../drizzle/migrations';

let dbPromise: Promise<AppDb> | null = null;

export function getDb(): Promise<AppDb> {
  if (!dbPromise) {
    dbPromise = (async () => {
      const conn = await openConnection('app');
      await runMigrations(conn, bundle);
      return createDb(conn);
    })();
  }
  return dbPromise;
}

Migrasi dengan Drizzle Kit

migrator bawaan Drizzle sqlite-proxy migrator membaca .sql file dari disk menggunakan Node fs, yang tidak dimiliki WebView. Solusinya, biarkan Drizzle Kit membundel SQL ke dalam modul JavaScript dan terapkan sendiri.

1. Konfigurasi Drizzle Kit

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'sqlite',
  driver: 'expo',
});

driver: 'expo' tidak berarti Anda perlu Expo. Ini memberi tahu Drizzle Kit agar juga menulis drizzle/migrations.js, yang mengimpor jurnal dan setiap .sql file. Itulah format yang dibutuhkan runtime non-Node mana pun.

2. Generate

bunx drizzle-kit generate

You get drizzle/0000_<name>.sql, drizzle/meta/_journal.json dan drizzle/migrations.js. Commit ketiga file tersebut. Jalankan generate lagi setiap kali ada perubahan skema. Jangan edit file SQL yang sudah dirilis.

3. Biarkan bundler mengimpor .sql sebagai teks

migrations.js berisi baris seperti import m0000 from './0000_fearless_selene.sql'. Vite tidak tahu .sql, jadi tambahkan plugin kecil saja alih-alih dependency lain:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    {
      name: 'sql-as-text',
      transform(code, id) {
        if (id.endsWith('.sql')) {
          return { code: `export default ${JSON.stringify(code)};`, map: null };
        }
      },
    },
  ],
});

Dan deklarasi tipe agar TypeScript menerima import-nya:

// src/sql.d.ts
declare module '*.sql' {
  const content: string;
  export default content;
}

Dengan webpack (custom builder Angular, setup lama) gunakan asset/source rule untuk .sql file sebagai gantinya.

4. Jalankan migrasi saat startup

// src/db/migrate.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';

interface MigrationBundle {
  journal: { entries: { idx: number; when: number; tag: string }[] };
  migrations: Record<string, string>;
}

export async function runMigrations(conn: SQLiteDBConnection, bundle: MigrationBundle) {
  await conn.execute(
    `CREATE TABLE IF NOT EXISTS __drizzle_migrations (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       hash TEXT NOT NULL,
       created_at NUMERIC
     )`,
    false,
  );

  const res = await conn.query(
    'SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1',
  );
  const lastApplied = Number(res.values?.[0]?.created_at ?? 0);

  for (const entry of bundle.journal.entries) {
    if (entry.when <= lastApplied) continue;

    const key = `m${entry.idx.toString().padStart(4, '0')}`;
    const sqlText = bundle.migrations[key];
    if (!sqlText) throw new Error(`Missing migration ${entry.tag}`);

    const statements = sqlText
      .split('--> statement-breakpoint')
      .map((s) => s.trim())
      .filter(Boolean);

    await conn.beginTransaction();
    try {
      for (const statement of statements) {
        await conn.execute(statement, false);
      }
      await conn.run(
        'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
        [entry.tag, entry.when],
        false,
      );
      await conn.commitTransaction();
    } catch (error) {
      await conn.rollbackTransaction();
      throw error;
    }
  }
}

Ini mengikuti aturan yang sama dengan migrator Drizzle: migrasi dianggap pending jika timestamp journal-nya lebih baru daripada yang terbaru yang tercatat. Setiap migrasi berjalan dalam transaksinya sendiri, jadi jika gagal, state sebelumnya tetap utuh. Memanggilnya setiap kali aplikasi dibuka tidak mahal.

Jika Anda merilis JavaScript dengan Live updates Capgo, migrasi baru akan sampai ke user tanpa perlu rilis ke store. Pastikan migrasinya bersifat additive (tabel baru, kolom nullable baru), karena rollback ke bundle lama akan berjalan terhadap schema yang lebih baru.

Query

import { and, desc, eq, sql } from 'drizzle-orm';
import { getDb } from './db';
import { posts, users } from './db/schema';

const db = await getDb();

// Insert and get the row back
const [ada] = await db
  .insert(users)
  .values({ name: 'Ada', email: 'ada@example.com' })
  .returning();

// Bulk insert
await db.insert(posts).values([
  { authorId: ada.id, title: 'Draft' },
  { authorId: ada.id, title: 'Live', published: true },
]);

// Select with filters
const live = await db
  .select()
  .from(posts)
  .where(and(eq(posts.authorId, ada.id), eq(posts.published, true)))
  .orderBy(desc(posts.id));

// Single row or undefined
const user = await db.select().from(users).where(eq(users.email, 'ada@example.com')).get();

// Update and delete
await db.update(posts).set({ published: true }).where(eq(posts.id, live[0].id));
await db.delete(posts).where(eq(posts.published, false));

// Aggregate
const [{ count }] = await db
  .select({ count: sql<number>`count(*)` })
  .from(posts);

Query relasional

const authors = await db.query.users.findMany({
  with: { posts: { where: (p, { eq }) => eq(p.published, true) } },
});

const one = await db.query.users.findFirst({
  where: (u, { eq }) => eq(u.id, ada.id),
  with: { posts: true },
});

Drizzle membangunnya dengan fungsi JSON SQLite, lalu mem-parsing hasilnya. Ini berfungsi di iOS dan versi Android terbaru. Jika minSdkVersion sudah lama, periksa SELECT json('{}') pada perangkat tes tertua Anda.

Joins: alias nama kolom duplikat

Ini satu-satunya titik rawan pada proxy driver apa pun yang dibangun di atas object rows. Drizzle tidak menambahkan AS alias ke kolom yang di-join, jadi query ini:

// Wrong: both columns are named "id" in the result set
await db
  .select({ postId: posts.id, userId: users.id })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id));

menghasilkan dua kolom bernama id. Keduanya menyatu menjadi satu key di row object, array-nya kurang satu nilai, dan Drizzle menetapkan nilai ke field yang salah. Dalam pengujian saya, postId kembali dengan user id dan userId adalah undefined. Tidak ada error yang dilempar.

Alias-kan secara eksplisit:

await db
  .select({
    postId: sql<number>`${posts.id}`.as('post_id'),
    userId: sql<number>`${users.id}`.as('user_id'),
    title: posts.title,
    author: users.name,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id));

Memilih kolom dengan nama berbeda (posts.title, users.name) tidak masalah. Hindari db.select().from(a).innerJoin(b, ...) tanpa daftar field ketika kedua tabel berbagi nama kolom seperti id atau created_at. Query relasional tidak memiliki masalah ini.

Transaksi

await db.transaction(async (tx) => {
  const [user] = await tx
    .insert(users)
    .values({ name: 'Grace', email: 'grace@example.com' })
    .returning();

  await tx.insert(posts).values({ authorId: user.id, title: 'Hello' });

  // Nested transactions become savepoints
  await tx.transaction(async (inner) => {
    await inner.insert(posts).values({ authorId: user.id, title: 'Maybe' });
  });
});

Jika callback melempar error, Drizzle mengirim rollback. Memanggil tx.rollback() juga membatalkan transaksi, dengan melempar error.

Hanya ada satu koneksi per database, jadi jaga transaksi tetap singkat dan jangan lakukan await panggilan jaringan di dalamnya. Penulisan lain pada koneksi yang sama akan berjalan di dalam transaksi Anda yang masih terbuka.

Pemecahan Masalah

Error “Connection already exists” saat startup. Inisialisasi Anda berjalan dua kali, biasanya karena hot reload. Gunakan checkConnectionsConsistency dan retrieveConnection pola yang ditunjukkan di atas.

cannot start a transaction within a transaction. Sebuah run() dipanggil tanpa false sebagai argumen ketiga, sehingga plugin membuka transaksinya sendiri di dalam transaksi Drizzle.

Migrations dijalankan setiap kali aplikasi dibuka dan gagal dengan table already exists. Panduan __drizzle_migrations insert hilang atau di-rollback. Periksa bahwa journal when di bundle Anda cocok dengan baris di tabel.

Error build Failed to parse source for import analysis aktif .sql. Plugin transform Vite tidak ada atau berjalan setelah plugin lain yang mencoba mem-parsing file. Letakkan di urutan pertama dalam plugins array.

.returning() mengembalikan array kosong. Tulisan berhasil run dengan return mode no. Pastikan driver meneruskan 'all' saat method bukan run.

Tanggal meleset dengan faktor 1000. mode: 'timestamp' menyimpan detik, mode: 'timestamp_ms' menyimpan milidetik. Pilih salah satu per kolom dan jangan dicampur dengan Date.now() insert mentah.

Kapan sebaiknya memilih opsi lain

Menambahkan plugin native berarti harus build ulang aplikasi iOS dan Android. Jika tidak ada Mac di sekitar Anda, Build Capgo melakukan build dan signing keduanya di cloud.

Live updates untuk aplikasi Capacitor

Saat bug web-layer sudah live, kirim perbaikannya lewat Capgo alih-alih menunggu berhari-hari untuk persetujuan app store. Pengguna mendapat update di background, sementara perubahan native tetap mengikuti jalur review normal.

Terbaru dari Blog kami

Capgo memberikan insight terbaik yang Anda butuhkan untuk membuat aplikasi mobile yang benar-benar profesional.