Langsung ke konten utama

Cara Menggunakan TypeORM dengan Capacitor dan SQLite

Gunakan TypeORM dengan Capacitor dan SQLite di iOS dan Android: setup DataSource, entity yang kompatibel dengan Vite, repository, transaksi, dan migration yang di-generate.

Logo Capacitor untuk panduan penggunaan TypeORM dengan Capacitor dan SQLite

TypeORM berjalan di aplikasi Capacitor melalui capacitor driver bawaan, yang menjalankan setiap query di @capacitor-community/sqlite. Anda mengonfigurasi DataSource dengan plugin SQLiteConnectionplugin, mendefinisikan entitas dengan decorator, dan menggunakan repository serta transaksi seperti di server. Panduan ini mencakup setup lengkap pada TypeORM 1.x dan Capacitor 8, termasuk dua hal yang sering dilewati tutorial lain: tipe kolom eksplisit untuk Vite, dan cara membuat migrasi meskipun CLI tidak bisa berkomunikasi dengan ponsel.

Kapan TypeORM cocok digunakan

TypeORM adalah ORM data-mapper dengan entitas, relasi, repository, dan sistem migrasi. Di mobile, ini masuk akal ketika:

  • Anda sudah menggunakan TypeORM di backend dan ingin gaya entitas yang sama di aplikasi,
  • model data Anda memiliki banyak relasi dan Anda ingin relations: { posts: true } daripada join yang ditulis manual,
  • tim Anda lebih memilih class dan decorator daripada objek skema.

Ini lebih berat daripada query builder. Jika ukuran bundle dan waktu startup paling penting, atau Anda ingin menggunakan @capgo/capacitor-fast-sql, lihat Kysely atau Drizzle instead.

Instal

bun add typeorm @capacitor-community/sqlite
bunx cap sync

Import TypeORM 1.x reflect-metadata mengimpor dirinya sendiri, jadi Anda tidak perlu import terpisah di bagian atas main.ts seperti panduan lama. Plugin komunitas ini membundel SQLCipher di iOS dan Android, baik Anda mengenkripsi maupun tidak; ini penting saat menjawab pertanyaan kepatuhan ekspor App Store.

Pengaturan TypeScript

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}

experimentalDecorators mengaktifkan decorator legacy TypeORM. useDefineForClassFields: false mencegah class field initializers menimpa nilai yang diatur TypeORM; ini penting saat target adalah ES2022 atau yang lebih baru.

Entity yang kompatibel dengan Vite dan esbuild

Sebagian besar aplikasi Capacitor dibangun dengan Vite, yang mengompilasi TypeScript menggunakan esbuild. esbuild mendukung decorator tetapi tidak mengimplementasikan emitDecoratorMetadata, sehingga TypeORM tidak dapat membaca tipe properti saat runtime. Tanpa tipe eksplisit, Anda akan mendapatkan ColumnTypeUndefinedError. Berikan tipe untuk setiap kolom:

// src/db/entities/author.ts
import {
  Column,
  CreateDateColumn,
  Entity,
  OneToMany,
  PrimaryGeneratedColumn,
  type Relation,
} from 'typeorm';
import { Post } from './post';

@Entity('authors')
export class Author {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column('text')
  name!: string;

  @Column('text', { unique: true })
  email!: string;

  @CreateDateColumn()
  createdAt!: Date;

  @OneToMany(() => Post, (post) => post.author)
  posts!: Relation<Post[]>;
}
// src/db/entities/post.ts
import {
  Column,
  Entity,
  Index,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
  type Relation,
} from 'typeorm';
import { Author } from './author';

@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column('text')
  title!: string;

  @Column('boolean', { default: false })
  published!: boolean;

  @Index()
  @Column('integer')
  authorId!: number;

  @ManyToOne(() => Author, (author) => author.posts, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'authorId' })
  author!: Relation<Author>;
}

Catatan:

  • Relation<T> adalah wrapper khusus tipe. Ini menghindari masalah impor sirkular antara author.ts dan post.ts jika Anda pernah mengompilasi dengan metadata decorator (misalnya di CLI atau test).
  • Kolom eksplisit authorId memungkinkan Anda memfilter dan menyisipkan berdasarkan id tanpa memuat author.
  • SQLite tidak punya tipe boolean. TypeORM menyimpan boolean kolom sebagai 0/1 lalu mengonversinya kembali.

Konfigurasi DataSource

// src/db/data-source.ts
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite';
import { DataSource } from 'typeorm';
import { Author } from './entities/author';
import { Post } from './entities/post';
import { Init1790000000000 } from './migrations/1790000000000-Init';

export const sqliteConnection = new SQLiteConnection(CapacitorSQLite);

export const AppDataSource = new DataSource({
  type: 'capacitor',
  driver: sqliteConnection,
  database: 'app',
  mode: 'no-encryption',
  entities: [Author, Post],
  migrations: [Init1790000000000],
  synchronize: false,
  migrationsRun: false,
  journalMode: 'WAL',
  logging: ['error'],
});

Opsi per opsi:

  • driver: milik plugin SQLiteConnection. Driver TypeORM menggunakannya untuk membuat dan membuka koneksi, lalu mengaktifkan PRAGMA foreign_keys agar onDelete: 'CASCADE' berfungsi.
  • database: plugin menambahkan SQLite.db suffix, sehingga file tersebut appSQLite.db.
  • mode: no-encryption, atau salah satu mode enkripsi plugin (secret, encryption). Lihat mengenkripsi SQLite di Capacitor.
  • synchronize: false: di produksi, selalu. Sync dapat menghapus kolom agar sesuai dengan entitas, yang akan menghapus data pengguna di perangkat.
  • migrationsRun: false: dokumentasi plugin meminta ini dengan tipe capacitor. Jalankan migrasi sendiri tepat setelah initialize(), seperti yang ditunjukkan di bawah.
  • journalMode: 'WAL': opsional. Konkurensi yang lebih baik antara baca dan tulis.
  • migrations: referensi kelas, bukan glob. Tidak ada pemindaian file system di WebView.

Pola glob seperti entities: ['src/**/*.entity.ts'] tidak berfungsi di aplikasi karena alasan yang sama. Impor kelasnya.

Inisialisasi saat startup

// src/db/index.ts
import { Capacitor } from '@capacitor/core';
import { AppDataSource, sqliteConnection } from './data-source';

let ready: Promise<typeof AppDataSource> | null = null;

export function initDatabase() {
  if (!ready) {
    ready = (async () => {
      if (Capacitor.getPlatform() === 'web') {
        // Needs the jeep-sqlite element in the DOM, see "Web support"
        await sqliteConnection.initWebStore();
      }

      // Clean up connections left over from a hot reload
      await sqliteConnection.checkConnectionsConsistency();

      if (!AppDataSource.isInitialized) {
        await AppDataSource.initialize();
      }
      await AppDataSource.runMigrations({ transaction: 'each' });

      if (Capacitor.getPlatform() === 'web') {
        await sqliteConnection.saveToStore('app');
      }
      return AppDataSource;
    })();
  }
  return ready;
}

Panggil await initDatabase() sebelum aplikasi Anda merender apa pun yang membaca data. transaction: 'each' membungkus setiap migration dalam transaksinya sendiri, sehingga kegagalan akan membiarkan migration sebelumnya tetap diterapkan dan yang gagal di-rollback.

Repository: insert, select, update, delete

import { initDatabase } from './db';
import { Author } from './db/entities/author';
import { Post } from './db/entities/post';

const ds = await initDatabase();
const authors = ds.getRepository(Author);
const posts = ds.getRepository(Post);

// Insert
const ada = await authors.save(authors.create({ name: 'Ada', email: 'ada@example.com' }));
await posts.save([
  posts.create({ title: 'Draft', authorId: ada.id }),
  posts.create({ title: 'Live', authorId: ada.id, published: true }),
]);

// Select
const published = await posts.find({
  where: { authorId: ada.id, published: true },
  order: { id: 'DESC' },
});
const one = await authors.findOneBy({ email: 'ada@example.com' });

// Load a relation
const withPosts = await authors.findOne({
  where: { id: ada.id },
  relations: { posts: true },
});

// Update without loading
await posts.update({ authorId: ada.id }, { published: true });

// Delete
await posts.delete({ published: false });

save() menyisipkan atau memperbarui tergantung pada apakah primary key sudah diset, dan mengembalikan entitas dengan id yang dihasilkan. Untuk penulisan massal di mana Anda tidak perlu mengembalikan entitas, insert() lebih cepat karena melewati pencarian tambahan.

Query builder untuk kebutuhan yang kompleks

const stats = await ds
  .getRepository(Author)
  .createQueryBuilder('a')
  .leftJoin('a.posts', 'p')
  .select('a.id', 'id')
  .addSelect('a.name', 'name')
  .addSelect('COUNT(p.id)', 'postCount')
  .groupBy('a.id')
  .getRawMany<{ id: number; name: string; postCount: number }>();

Joins dan agregasi adalah tempat di mana ORM menghemat waktu Anda di mobile. Periksa SQL yang dihasilkan dengan .getSql() selama pengembangan, dan tambahkan indeks untuk kolom yang digunakan dalam WHERE dan JOIN.

Transaksi

await ds.transaction(async (manager) => {
  const grace = await manager.save(Author, { name: 'Grace', email: 'grace@example.com' });
  await manager.save(Post, { title: 'Hello', authorId: grace.id });
});

Gunakan manager dilewatkan ke callback untuk setiap query di dalam transaksi. Jika callback melempar error, TypeORM akan rollback.

Driver Capacitor memakai satu koneksi, jadi tidak ada isolasi antara transaksi dan pekerjaan lain: query yang dijalankan melalui ds atau repository lain saat transaksi masih terbuka akan berjalan di koneksi yang sama dan ikut di-commit atau di-rollback bersamanya. Jangan memulai pekerjaan database yang tidak terkait sampai transaksi selesai, buat transaksi tetap singkat, dan jangan pernah menunggu request jaringan di dalamnya.

Migrations: generate dengan DataSource sqljs lokal

TypeORM CLI berjalan di komputer Anda, bukan di ponsel, sehingga tidak bisa memeriksa database perangkat. Dokumentasi plugin sendiri menyatakan bahwa migrasi untuk tipe capacitor harus ditulis manual. Anda tetap bisa mendapatkan migrasi yang dihasilkan: sqljs driver TypeORM memakai dialek SQLite yang sama, sehingga SQL yang dihasilkannya dijalankan perangkat tanpa perubahan.

1. Tambahkan DataSource khusus CLI

bun add -d sql.js
// typeorm.cli.ts (project root, not bundled into the app)
import { DataSource } from 'typeorm';
import { Author } from './src/db/entities/author';
import { Post } from './src/db/entities/post';

export default new DataSource({
  type: 'sqljs',
  location: '.typeorm/schema.sqlite',
  autoSave: true,
  entities: [Author, Post],
  migrations: ['src/db/migrations/*.ts'],
});

Tambah .typeorm/ ke .gitignore. File itu adalah salinan sementara dari “seperti apa skema setelah semua migrasi yang ada”.

2. Perbarui database scratch, lalu jalankan diff

mkdir -p .typeorm
bun ./node_modules/typeorm/cli.js migration:run -d ./typeorm.cli.ts
bun ./node_modules/typeorm/cli.js migration:generate -d ./typeorm.cli.ts src/db/migrations/AddTags

Bun menjalankan data source dan entitas TypeScript secara langsung. Perintah pertama menerapkan migrasi yang ada ke database sementara. Perintah kedua membandingkannya dengan entitas Anda dan menulis kelas migrasi baru, misalnya src/db/migrations/1790000123456-AddTags.ts. Lalu tambahkan class tersebut ke migrations array di data-source.ts.

Migrasi awal yang dihasilkan terlihat seperti ini:

import type { MigrationInterface, QueryRunner } from 'typeorm';

export class Init1790000000000 implements MigrationInterface {
  name = 'Init1790000000000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `CREATE TABLE "authors" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" text NOT NULL, "email" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_authors_email" UNIQUE ("email"))`,
    );
    await queryRunner.query(
      `CREATE TABLE "posts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "published" boolean NOT NULL DEFAULT (0), "authorId" integer NOT NULL, CONSTRAINT "FK_posts_author" FOREIGN KEY ("authorId") REFERENCES "authors" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`,
    );
    await queryRunner.query(`CREATE INDEX "IDX_posts_authorId" ON "posts" ("authorId")`);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX "IDX_posts_authorId"`);
    await queryRunner.query(`DROP TABLE "posts"`);
    await queryRunner.query(`DROP TABLE "authors"`);
  }
}

Baca setiap migrasi yang dihasilkan sebelum dirilis. Untuk SQLite, TypeORM sering mengubah tabel dengan membuat temporary_<table>, menyalin baris, drop tabel asli, dan rename. Itu benar, tapi pada tabel besar butuh waktu di ponsel, jadi jalankan dengan loading state.

Menulis migrations secara manual

Jika Anda menulis migrasi sendiri, awali setiap statement dengan keyword huruf besar. Query runner capacitor melihat kata pertama secara case-sensitive untuk menentukan cara memanggil plugin: BEGIN, COMMIT, ROLLBACK, CREATE, ALTER dan DROP buka execute, INSERT, UPDATE dan DELETE buka run, dan sisanya masuk ke query. Huruf kecil create table dikirim sebagai query, yang bukan yang Anda inginkan untuk DDL.

Migrations dan live updates

Kelas migrasi adalah bagian dari bundle JavaScript Anda. Dengan Live updates Capgo perubahan skema mencapai pengguna tanpa review store. Rollback kemudian dapat menempatkan code yang lebih lama pada skema yang lebih baru, jadi lebih baik gunakan migrasi aditif (tabel baru, kolom nullable baru) dan hapus kolom lama di rilis berikutnya.

Dukungan Web

Plugin komunitas berjalan di web melalui jeep-sqlite komponen web (sql.js dipersist ke IndexedDB). Berguna untuk pengembangan di browser:

bun add jeep-sqlite
cp node_modules/sql.js/dist/sql-wasm.wasm public/assets/
// main.ts, before initDatabase()
import { defineCustomElements as jeepSqlite } from 'jeep-sqlite/loader';
import { Capacitor } from '@capacitor/core';

if (Capacitor.getPlatform() === 'web') {
  jeepSqlite(window);
  const el = document.createElement('jeep-sqlite');
  document.body.appendChild(el);
  await customElements.whenDefined('jeep-sqlite');
}

Di web, database berada di memori dan ditulis ke IndexedDB saat Anda memanggil sqliteConnection.saveToStore('app'). Panggil setelah penulisan yang penting, atau data akan hilang saat reload.

Pemecahan Masalah

Column type for Post#title is not defined and cannot be guessed. esbuild tidak mengeluarkan metadata. Tambahkan tipe eksplisit ke @Column decorator.

Capacitor package has not been found installed. Panduan driver option hilang atau undefined. Sampaikan new SQLiteConnection(CapacitorSQLite).

Error “connection already exists” saat reload. Inisialisasi berjalan dua kali. Cukup gunakan satu ready promise dan panggil checkConnectionsConsistency() sebelum initialize().

No metadata for "Post" was found. Kelas entitas tidak ada di dalam entities array, atau Anda menggunakan glob string. Import kelasnya.

Data direset setiap kali update app saat development. synchronize: true aktif, atau migrasi menghapus dan membuat ulang tabel. Matikan sync dan cek SQL yang dihasilkan.

Migrasi berjalan di CLI tapi tidak di perangkat. Kelas baru tidak ada di dalam migrations array di data-source.ts. App tidak memindai folder.

Kesimpulan

TypeORM di Capacitor terdiri dari tiga bagian: plugin SQLite komunitas sebagai driver, entitas dengan tipe kolom eksplisit, dan migrasi yang dibuat terhadap database lokal sqljs database dan diimpor sebagai kelas. Dengan itu, repositori, relasi, dan transaksi berperilaku sama seperti di backend Anda.

Untuk setup yang lebih ringan di @capgo/capacitor-fast-sql, lihat Kysely dengan Capacitor. Untuk dasar-dasar plugin, mulai dengan Cara menggunakan SQLite di aplikasi Capacitor. Ketika sisi native berubah, Build Capgo dapat rebuild dan menandatangani aplikasi iOS dan Android 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.