メインコンテンツへスキップ

Capacitor と SQLite で TypeORM を使用する方法

iOS と Android で Capacitor と SQLite で TypeORM を使う方法:DataSource のセットアップ、Vite 対応の entity、repository、transaction、および生成された migration について解説します。

Capacitor と SQLite で TypeORM を使用する方法

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 から始めましょう。

Capacitor アプリ向けの Live Update

Webレイヤーのバグが本番に出たときは、アプリストアの審査を何日も待たずに Capgo で修正を配信できます。ネイティブの変更は通常の審査フローに乗せたまま、ユーザーにはバックグラウンドでアップデートが届きます。

最新のブログ記事

Capgo は、本格的なプロフェッショナルモバイルアプリの開発に必要な最高のインサイトを提供します。