TypeORM は、組み込みの Capacitor アプリ向け capacitor driver を通じて動作し、すべてのクエリを @capacitor-community/sqlite. ここでは DataSource をプラグインの SQLiteConnectionで構成し、デコレータでエンティティを定義し、サーバーと同様にリポジトリやトランザクションを使用します。このガイドでは、TypeORM 1.x と Capacitor 8 の完全なセットアップを解説します。多くのチュートリアルが省略する2つのポイント、Vite 向けの明示的なカラム型と、CLI が実機と通信できないのにマイグレーションを生成する方法もカバーします。
TypeORM が適しているケース
TypeORM は、エンティティ、リレーション、リポジトリ、マイグレーションシステムを備えたデータマッパー型 ORM です。モバイルでは次のような場合に適しています:
- バックエンドですでに TypeORM を使っていて、アプリでも同じエンティティスタイルにしたい場合、
- データモデルに多くのリレーションがあり、
relations: { posts: true }手書きの join の代わりに使いたい場合、 - チームがスキーマオブジェクトよりもクラスやデコレータを好む場合。
クエリビルダーより重めです。バンドルサイズや起動時間を最優先する場合、または @capgo/capacitor-fast-sql、次を参照してください: Kysely または Drizzle 代わりに。
インストール
bun add typeorm @capacitor-community/sqlite
bunx cap sync
TypeORM 1.x では、 reflect-metadata 自身をインポートするため、ファイルの先頭に別途インポートを main.ts 記述する必要はありません(古いガイドとは異なります)。コミュニティプラグインは、暗号化の有無に関わらず iOS と Android に SQLCipher を同梱します。これは App Store の輸出コンプライアンスに関する質問に回答する際に重要です。
TypeScript の設定
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}
experimentalDecorators で、TypeORM のレガシーデコレータを有効にします。 useDefineForClassFields: false により、クラスフィールドの初期化子が、TypeORM が設定する値を上書きするのを防ぎます。これは、ターゲット target が ES2022 以降の場合に重要です。
Vite と esbuild で動作する Entity
ほとんどの Capacitor アプリは Vite でビルドされており、esbuild で TypeScript をコンパイルします。esbuild はデコレータをサポートしていますが、 emitDecoratorMetadata を実装していないため、TypeORM は実行時にプロパティの型を読み取れません。明示的な型がないと、 ColumnTypeUndefinedError が発生します。各カラムに型を指定してください:
// 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>;
}
注意:
Relation<T>は型のみのラッパーです。これにより、author.tsとpost.ts間での循環参照問題を回避できます(例えば CLI やテストでデコレータメタデータを使ってコンパイルする場合など)。- 明示的な
authorIdカラムを使えば、author をロードせずに id でフィルタリングや挿入ができます。 - SQLite に boolean 型はありません。TypeORM は
booleanカラムを0/1として保存し、読み込み時に元に戻します。
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'],
});
各オプションの詳細:
driver: プラグインのSQLiteConnection. TypeORMのドライバーはこれを使用して接続の作成とオープンを行い、その後PRAGMA foreign_keysなのでonDelete: 'CASCADE'動作します。database: プラグインがSQLite.dbサフィックスを追加するため、ファイル名はappSQLite.db.mode:no-encryption, またはプラグインの暗号化モードのいずれか(secret,encryption)。詳細は Capacitor で SQLite を暗号化する方法を参照してください.synchronize: false: 本番環境では必ず設定してください。Syncを使用すると、エンティティに合わせてカラムが削除され、デバイス上のユーザーデータが消失する可能性があります。migrationsRun: false: プラグインのドキュメントでは capacitor 型が指定されています。直後に手動でマイグレーションを実行してください。initialize()、詳細は以下を参照してください。journalMode: 'WAL': オプション。読み取りと書き込みの並行処理が向上します。migrations: globではなくクラスを参照してください。WebView内ではファイルシステムの走査は行われません。
次のようなglobパターンは entities: ['src/**/*.entity.ts'] 同様の理由でアプリ内では動作しません。クラスを直接importしてください。
起動時に初期化
// 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;
}
呼び出し await initDatabase() データを読み取る処理がレンダリングされる前に実行してください。 transaction: 'each' 各マイグレーションを個別のトランザクションでラップするため、失敗してもそれまでのマイグレーションは適用され、失敗したものはロールバックされます。
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() 主キーの設定有無に応じてinsertまたはupdateを行い、生成されたIDを含むエンティティを返します。エンティティの戻り値が不要な一括書き込みの場合は、 insert() 余分なルックアップをスキップするため、より高速です。
複雑なクエリにも対応できるクエリビルダー
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 }>();
モバイル開発において、ORMはJoinや集計処理で時間を節約してくれます。生成されたSQLは以下で確認できます。 .getSql() 開発中に確認し、使用するカラムにはインデックスを追加しましょう。 WHERE と JOIN.
トランザクション
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 });
});
以下の manager を使用すると、トランザクション内の各クエリに対してコールバックに渡されます。コールバック内でエラーがスローされると、TypeORMはロールバックを実行します。
Capacitor ドライバーは単一の接続を使用するため、トランザクションと他の処理との間に分離がありません。トランザクション実行中に ds や他のリポジトリを通じて行われたクエリは、同じ接続上で実行され、トランザクションと共にコミットまたはロールバックされます。トランザクションが完了するまで無関係なDB操作を開始せず、トランザクションは短く保ち、内部でネットワークリクエストを待機させないでください。
マイグレーション:ローカルの sqljs DataSource で生成します
TypeORMの CLI はデバイス上ではなくコンピュータ上で動作するため、デバイス内のデータベースを直接検査することはできません。プラグインのドキュメントにもある通り、capacitor 型のマイグレーションは手動で記述する必要があります。ただし、TypeORMの機能を使ってマイグレーションを生成することは可能です。 sqljs ドライバーは同じSQLite方言を使用するため、デバイス上でそのまま実行可能なSQLを生成します。
1. CLI専用のDataSourceを追加します
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'],
});
追加 .typeorm/ へ .gitignore。このファイルは「既存のすべてのマイグレーション適用後のスキーマ状態」を記録した一時的なコピーです。
2. 一時的なデータベースを最新の状態に更新し、差分を確認します
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はTypeScriptのDataSourceとEntityを直接実行します。最初のコマンドで既存のマイグレーションを一時的なデータベースに適用し、2番目のコマンドでEntityとの比較を行い、新しいマイグレーションクラスを作成します。例: src/db/migrations/1790000123456-AddTags.ts。その後、そのクラスを migrations 内の data-source.ts.
配列に追加します
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"`);
}
}
生成された初期マイグレーションは以下のようになります: temporary_<table>生成されたマイグレーションは、リリース前に必ず内容を確認してください。SQLiteの場合、TypeORMはテーブルの変更を行う際、新しいテーブルを作成し、行をコピーし、元のテーブルを削除して名前を変更するという手順をよく取ります。これは正しい動作ですが、大きなテーブルではモバイル端末での実行に時間がかかるため、ローディング状態を表示するようにしてください。
手動でのマイグレーション作成
マイグレーションを自作する場合、すべてのステートメントを大文字のキーワードで始めてください。capacitor query runner は、最初の単語を大文字・小文字を区別して判別し、プラグインの呼び出し方法を決定します: BEGIN, COMMIT, ROLLBACK, CREATE, ALTER と DROP へ送られます execute, INSERT, UPDATE と DELETE へ送られます run、それ以外はすべて queryへ送られます。小文字の create table はクエリとして送信されますが、これは DDL において意図しない動作となります。
マイグレーションと Live Update
Migration クラスは JavaScript bundle の一部となります。 Capgo による Live Update ストアの審査を通さずにスキーマ変更をユーザーに配信できます。ロールバックによって、新しいスキーマに対して古い code を適用することも可能です。そのため、マイグレーションは既存の構成を壊さない追加型(新しいテーブルや、NULL許容の新しいカラムの追加)を優先し、古いカラムの削除は後続のリリースで行うようにしてください。
Webサポート
コミュニティプラグインは、以下の仕組みを通じてWeb上で動作します: jeep-sqlite Webコンポーネント(sql.jsをIndexedDBに永続化)。ブラウザでの開発に非常に便利です:
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');
}
Web上ではデータベースはメモリ内に保持され、呼び出し時にIndexedDBへ書き込まれます sqliteConnection.saveToStore('app')重要な書き込みを行った後に呼び出してください。そうしないと、リロード時にデータが失われます。
トラブルシューティング
Column type for Post#title is not defined and cannot be guessed. esbuild がメタデータを出力しませんでした。明示的な型を以下に追加してください: @Column デコレータ
Capacitor package has not been found installed. の driver option is missing or undefined. Pass new SQLiteConnection(CapacitorSQLite).
リロード時に「connection already exists」エラーが発生します。 初期化が2回実行されました。単一の ready promiseを保持し、呼び出す checkConnectionsConsistency() 前に initialize().
No metadata for "Post" was found. エンティティクラスが entities 配列に含まれていないか、glob文字列を使っています。クラスをimportしてください。
開発中はアプリ更新のたびにデータがリセットされます。 synchronize: true がオンになっているか、マイグレーションでテーブルが削除・再作成されています。syncをオフにして生成されたSQLを確認してください。
マイグレーションはCLIで実行されましたが、デバイス上では実行されていません。 新しいクラスが migrations 配列に含まれていません。 data-source.tsアプリはフォルダをスキャンしません。
まとめ
Capacitor 上での TypeORM は、ドライバーとなるコミュニティ版 SQLite プラグイン、明示的なカラム型を持つエンティティ、そしてローカル sqljs データベースに対して生成され、クラスとしてインポートされるマイグレーションの3要素で構成されます。これらが揃えば、リポジトリ、リレーション、トランザクションはバックエンドと同様に動作します。
より軽量な構成を @capgo/capacitor-fast-sqlについては、 Capacitor でのKysely を Capacitor アプリで SQLite を使用する方法プラグインの基本については、 Capgo Build から始めましょう。