Zum Hauptinhalt springen

TypeORM mit Capacitor und SQLite nutzen

Nutze TypeORM mit Capacitor und SQLite auf iOS und Android: DataSource-Setup, Vite-kompatible Entities, Repositories, Transaktionen und generierte Migrationen.

Capacitor-Logo für einen Leitfaden zur Verwendung von TypeORM mit Capacitor und SQLite

TypeORM funktioniert in einer Capacitor App über seinen integrierten capacitor Driver, der jede Query auf @capacitor-community/sqlite. Du konfigurierst eine DataSource mit dem Plugin SQLiteConnection, definierst Entities mit Decorators und nutzt Repositories sowie Transactions wie auf einem Server. Dieser Guide deckt das komplette Setup mit TypeORM 1.x und Capacitor 8 ab – inklusive der zwei Dinge, die die meisten Tutorials auslassen: explizite Spaltentypen für Vite und wie du Migrations generierst, obwohl die CLI nicht mit einem Smartphone kommunizieren kann.

Wann TypeORM sinnvoll ist

TypeORM ist ein Data-Mapper-ORM mit Entities, Relations, Repositories und einem Migrationssystem. Auf Mobile macht es Sinn, wenn:

  • du TypeORM bereits im Backend nutzt und denselben Entity-Style in der App haben möchtest,
  • dein Datenmodell viele Relations hat und du relations: { posts: true } statt manuell geschriebener Joins,
  • dein Team Klassen und Decorators gegenüber Schema-Objekten bevorzugt.

Es bringt mehr Overhead mit als ein Query Builder. Wenn Bundle-Größe und Startup-Zeit entscheidend sind oder du @capgo/capacitor-fast-sql, schau dir Kysely oder Drizzle stattdessen.

Installieren

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

TypeORM-1.x-Imports reflect-metadata selbst, sodass du keinen separaten Import am Anfang main.ts benötigst, wie ältere Guides zeigen. Das Community-Plugin bringt SQLCipher auf iOS und Android mit, egal ob du verschlüsselst oder nicht – was wichtig ist, wenn du die Export-Compliance-Fragen für den App Store beantwortest.

TypeScript-Einstellungen

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

experimentalDecorators aktiviert die Legacy-Decorators von TypeORM. useDefineForClassFields: false verhindert, dass Class-Field-Initialisierer Werte überschreiben, die TypeORM setzt. Das ist wichtig, wenn dein target ES2022 oder neuer ist.

Entities, die mit Vite und esbuild funktionieren

Die meisten Capacitor-Apps werden mit Vite gebaut, das TypeScript mit esbuild kompiliert. esbuild unterstützt Decorators, implementiert aber nicht emitDecoratorMetadata, daher kann TypeORM Property-Typen zur Laufzeit nicht lesen. Ohne explizite Typen erhältst du ColumnTypeUndefinedError. Gib jeder Spalte einen Typ:

// 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>;
}

Hinweise:

  • Relation<T> ist ein Wrapper nur für Typen. Er vermeidet zirkuläre Import-Probleme zwischen author.ts und post.ts falls du jemals mit Decorator-Metadaten kompilierst (z. B. in CLI oder Tests).
  • Die explizite authorId Spalte ermöglicht das Filtern und Einfügen per ID, ohne den Autor zu laden.
  • SQLite hat keinen Boolean-Typ. TypeORM speichert boolean Spalten als 0/1 und konvertiert sie zurück.

Konfiguriere die 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'],
});

Option für Option:

  • driver: des Plugins SQLiteConnection. Der TypeORM-Treiber nutzt sie, um die Verbindung zu erstellen und zu öffnen, und aktiviert dann PRAGMA foreign_keys damit onDelete: 'CASCADE' funktioniert.
  • database: Das Plugin fügt eine SQLite.db Suffix, sodass die Datei appSQLite.db.
  • mode: no-encryption, oder einer der Verschlüsselungsmodi des Pluginssecret, encryption). Siehe SQLite-Verschlüsselung in Capacitor.
  • synchronize: false: in der Produktion immer. Synchronisation kann Spalten entfernen, um sie an die Entities anzupassen, was Benutzerdaten auf dem Gerät löscht.
  • migrationsRun: false: Die Dokumentation des Plugins verlangt dies mit dem capacitor-Typ. Führe Migrationen selbst direkt danach aus initialize()wie unten gezeigt.
  • journalMode: 'WAL': optional. Bessere Parallelität zwischen Lese- und Schreibvorgängen.
  • migrations: Klassenreferenzen, kein Glob. Es gibt keinen Dateisystem-Scan in einer WebView.

Glob-Muster wie entities: ['src/**/*.entity.ts'] funktionieren aus demselben Grund nicht in der App. Importiere die Klassen.

Beim Start initialisieren

// 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;
}

Rufe await initDatabase() bevor deine App irgendetwas rendert, das Daten liest. transaction: 'each' wickelt jede Migration in eine eigene Transaction ein, sodass bei einem Fehler frühere Migrations angewendet bleiben und die fehlerhafte zurückgerollt wird.

Repositories: 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() fügt ein oder aktualisiert je nachdem, ob der Primary Key gesetzt ist, und gibt die Entity mit ihrer generierten ID zurück. Für Bulk-Writes, bei denen du keine Entities zurückbekommen musst, insert() ist es schneller, weil es die zusätzlichen Lookups überspringt.

Query Builder für alles, was nicht trivial ist

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 und Aggregationen sind der Bereich, in dem ein ORM dir auf Mobile Zeit spart. Prüfe den generierten SQL mit .getSql() während der Entwicklung und füge Indizes für Spalten hinzu, die in WHERE und JOIN.

Transaktionen

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 });
});

Nutze das manager an den Callback für jede Query innerhalb der Transaktion übergeben. Wirft der Callback eine Exception, rollt TypeORM zurück.

Der Capacitor-Driver verwendet eine einzelne Verbindung, daher gibt es keine Isolation zwischen der Transaktion und anderen Vorgängen: Eine Query, die über ds oder ein anderes Repository ausgeführt wird, während die Transaktion offen ist, läuft auf derselben Verbindung und wird mit ihr committet oder zurückgerollt. Starte keine unabhängigen Datenbankvorgänge, bis die Transaktion abgeschlossen ist, halte Transaktionen kurz und warte niemals auf eine Netzwerk-Anfrage innerhalb einer Transaktion.

Migrations: mit einem lokalen sqljs-DataSource generieren

Der TypeORM-CLI läuft auf deinem Computer, nicht auf dem Telefon, und kann die Geräte-Datenbank daher nicht inspizieren. Die Doku des Plugins besagt, dass Migrations für den capacitor-Typ manuell geschrieben werden müssen. Du kannst trotzdem generierte Migrations erhalten: Der sqljs Driver verwendet denselben SQLite-Dialekt und erzeugt SQL, das das Gerät unverändert ausführt.

1. Einen nur für CLI bestimmten DataSource hinzufügen

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'],
});

Füge .typeorm/ zu .gitignore. Diese Datei ist eine Arbeitskopie von „so sieht das Schema nach allen bestehenden Migrations aus“.

2. Die Arbeitsdatenbank aktualisieren und dann den Diff erstellen

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 führt den TypeScript-DataSource und die Entities direkt aus. Der erste Befehl wendet bestehende Migrations auf die Arbeitsdatenbank an. Der zweite vergleicht sie mit deinen Entities und schreibt eine neue Migration-Klasse, zum Beispiel src/db/migrations/1790000123456-AddTags.ts. Füge dann diese Klasse zur migrations Array in data-source.ts.

Eine generierte Initiale-Migration sieht so aus:

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"`);
  }
}

Lies jede generierte Migration, bevor du sie auslieferst. Bei SQLite ändert TypeORM oft eine Tabelle, indem es temporary_<table>erstellt, Zeilen kopiert, die Originaltabelle löscht und umbenennt. Das ist korrekt, aber bei einer großen Tabelle dauert es auf dem Handy, also führe es mit einem Ladezustand aus.

Migrationen manuell schreiben

Wenn du Migrationen selbst schreibst, beginne jede Anweisung mit einem Großbuchstaben-Keyword. Der capacitor Query Runner prüft das erste Wort, case-sensitiv, um zu entscheiden, wie das Plugin aufgerufen wird: BEGIN, COMMIT, ROLLBACK, CREATE, ALTER und DROP gehe zu execute, INSERT, UPDATE und DELETE gehe zu run, und alles andere geht zu query. Kleinbuchstaben create table wird als Query gesendet, was für DDL nicht gewünscht ist.

Migrationen und Live Updates

Migration-Klassen sind Teil deines JavaScript-Bundles. Mit Capgo Live Updates erreicht eine Schema-Änderung Nutzer ohne Store-Review. Ein Rollback kann dann ältere code auf ein neueres Schema setzen. Bevorzuge daher additive Migrations (neue Tabellen, neue nullable Spalten) und entferne alte Spalten in einer späteren Release.

Web-Support

Das Community-Plugin läuft im Web über das jeep-sqlite Web-Component (sql.js, persistiert in IndexedDB). Es ist nützlich für die Entwicklung im 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');
}

Im Web liegt die Datenbank im Speicher und wird in IndexedDB geschrieben, wenn du sqliteConnection.saveToStore('app')aufrufst. Rufe es nach Writes auf, die dir wichtig sind, sonst gehen Daten beim Reload verloren.

Fehlerbehebung

Column type for Post#title is not defined and cannot be guessed. esbuild hat keine Metadaten ausgegeben. Füge eine explizite Typangabe hinzu für @Column den Decorator.

Capacitor package has not been found installed. Der driver Die Option fehlt oder ist undefined. Übergebe new SQLiteConnection(CapacitorSQLite).

Ein „connection already exists“-Fehler beim Neuladen. Initialisierung lief zweimal. Behalte das einzige ready Promise und rufe auf checkConnectionsConsistency() bevor initialize().

No metadata for "Post" was found. Die Entity-Klasse ist nicht im entities Array, oder du hast einen Glob-String verwendet. Importiere die Klasse.

Daten werden bei jedem App-Update während der Entwicklung zurückgesetzt. synchronize: true ist aktiviert, oder Migrationen löschen Tabellen und erstellen sie neu. Schalte Sync aus und prüfe das generierte SQL.

Migrationen liefen in CLI, aber nicht auf dem Gerät. Die neue Klasse ist nicht im migrations Array in data-source.ts. Die App scannt keine Ordner.

Fazit

TypeORM auf Capacitor besteht aus drei Bausteinen: dem Community-SQLite-Plugin als Driver, Entities mit expliziten Spaltentypen und Migrations, die gegen eine lokale sqljs Datenbank generiert und als Klassen importiert werden. Damit verhalten sich Repositories, Relations und Transaktionen genauso wie auf deinem Backend.

Für ein leichteres Setup auf @capgo/capacitor-fast-sql, siehe Kysely mit Capacitor. Für die Plugin-Grundlagen starte mit So nutzt du SQLite in einer Capacitor App. Wenn sich die native Seite ändert, Capgo Build können die iOS- und Android-Apps in der Cloud neu gebaut und signiert werden.

Live-Updates für Capacitor-Apps

Wenn ein Bug im Web-Layer live ist, liefere den Fix über Capgo aus, statt Tage auf die App-Store-Freigabe zu warten. Nutzer bekommen das Update im Hintergrund, während native Änderungen im normalen Review-Prozess bleiben.

Neues aus unserem Blog

Capgo gibt dir die besten Insights, um eine wirklich professionelle Mobile App zu erstellen.