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 zwischenauthor.tsundpost.tsfalls du jemals mit Decorator-Metadaten kompilierst (z. B. in CLI oder Tests).- Die explizite
authorIdSpalte ermöglicht das Filtern und Einfügen per ID, ohne den Autor zu laden. - SQLite hat keinen Boolean-Typ. TypeORM speichert
booleanSpalten als0/1und 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 PluginsSQLiteConnection. Der TypeORM-Treiber nutzt sie, um die Verbindung zu erstellen und zu öffnen, und aktiviert dannPRAGMA foreign_keysdamitonDelete: 'CASCADE'funktioniert.database: Das Plugin fügt eineSQLite.dbSuffix, sodass die DateiappSQLite.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 ausinitialize()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.