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

Capacitor と SQLite で TypeORM を使う方法

iOS と Android で Capacitor と SQLite を使って TypeORM を利用する:DataSource のセットアップ、Vite で動く entity、repository、transaction、生成された migration について。

TypeORM を Capacitor と SQLite で使うガイド用の Capacitor ロゴ

TypeORM は Capacitor アプリで、組み込みの capacitor ドライバーを介して動作し、すべてのクエリを @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 で動作するエンティティ

ほとんどの Capacitor アプリは Vite でビルドされ、TypeScript は esbuild でコンパイルされます。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: クラスの参照を使用します。WebView にはファイルシステムのスキャン機能がないため、glob は使えません。

のような glob パターンは entities: ['src/**/*.entity.ts'] 同じ理由でアプリ内では動作しません。クラスをインポートしてください。

起動時に初期化

// 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() データを読み取る UI をレンダリングする前に実行してください。 transaction: 'each' 各マイグレーションを個別のトランザクションでラップするため、失敗してもそれ以前のマイグレーションは適用され、失敗した分だけロールバックされます。

リポジトリ: 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 }>();

モバイルでは、Joinや集計処理こそORMが時間を節約してくれる場面です。生成された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. スクラッチデータベースを最新の状態に更新し、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 は 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"`);
  }
}

リリース前に、生成されたすべてのマイグレーションを確認してください。SQLite の場合、TypeORM はテーブルを変更する際、多くの場合、新しいテーブルを作成し temporary_<table>、行をコピーし、元のテーブルを削除してリネームします。これは正しい動作ですが、大きなテーブルではスマートフォン上での処理に時間がかかるため、ローディング状態を表示しながら実行してください。

マイグレーションを手動で書く

マイグレーションを自分で書く場合、すべてのステートメントは大文字のキーワードで始めてください。capacitor query runner は最初の単語を大文字小文字を区別して確認し、プラグインをどう呼び出すかを決めます: BEGIN, COMMIT, ROLLBACK, CREATE, ALTER と DROP へ送られ execute, INSERT, UPDATE と DELETE へ送られ run、それ以外はすべて queryへ送られます。小文字の create table はクエリとして送信されるため、DDL には不向きです。

マイグレーションと Live Updates

Migration クラスは JavaScript bundle の一部です。次の場合: Capgo Live Updates スキーマ変更はストアのレビューなしでユーザーに反映されます。ロールバックにより、新しいスキーマに対して古い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 オプションが欠落または未定義です。値を渡してください new SQLiteConnection(CapacitorSQLite).

リロード時に「接続が既に存在します」エラーが発生します。 初期化が2回実行されました。単一の ready Promiseを保持して呼び出してください checkConnectionsConsistency() の前に initialize().

No metadata for "Post" was found. エンティティクラスが entities 配列に含まれていないか、glob文字列を使用しています。クラスをインポートしてください。

開発中にアプリを更新するたびにデータがリセットされます。 synchronize: true がオンになっているか、マイグレーションでテーブルが削除・再作成されています。syncをオフにして生成されたSQLを確認してください。

マイグレーションはCLIで実行されましたが、デバイス上では実行されていません。 新しいクラスが migrations 配列に追加します data-source.tsに含まれていません。アプリはフォルダをスキャンしません。

まとめ

Capacitor での TypeORM は3つの要素で構成されます。コミュニティ製 SQLite プラグインをドライバーとして使用し、明示的なカラム型を持つエンティティを定義し、ローカルの sqljs データベースに対して生成したマイグレーションをクラスとしてインポートします。これらが整えば、リポジトリ、リレーション、トランザクションはバックエンドと同じように動作します。

より軽量なセットアップには @capgo/capacitor-fast-sql次を参照してください: Capacitor と Kysely。プラグインの基本については、まず Capacitor アプリでSQLiteを使う方法。ネイティブ側に変更があった場合、 Capgo のビルド がクラウドで iOS と Android アプリを再ビルドして署名できます。

Capacitor アプリ向けの Live Updates

Web レイヤーのバグが本番で発生したら、App Store の審査を何日も待たずに Capgo で修正を配信しましょう。ネイティブの変更は通常の審査フローに残したまま、ユーザーはバックグラウンドでアップデートを受け取れます。

最新のブログ記事

Capgoは、真にプロフェッショナルなモバイルアプリを作るために必要な最高のインサイトを提供します。