Aller au contenu principal

Comment utiliser TypeORM avec Capacitor et SQLite

Utiliser TypeORM avec Capacitor et SQLite sur iOS et Android : configuration du DataSource, entités compatibles avec Vite, repositories, transactions et migrations générées.

Logo Capacitor pour un guide sur l'utilisation de TypeORM avec Capacitor et SQLite

TypeORM fonctionne dans une application Capacitor grâce à son capacitor driver intégré, qui exécute chaque requête sur @capacitor-community/sqlite. Vous configurez un DataSource avec le plugin SQLiteConnectiondu plugin, définissez vos entités avec des décorateurs et utilisez les repositories et les transactions comme sur un serveur. Ce guide couvre la configuration complète avec TypeORM 1.x et Capacitor 8, y compris les deux points que la plupart des tutoriels oublient : les types de colonnes explicites pour Vite, et comment générer des migrations même si le CLI ne peut pas communiquer avec un téléphone.

Quand choisir TypeORM

TypeORM est un ORM de type data-mapper doté d'entités, de relations, de repositories et d'un système de migration. Sur mobile, il est pertinent quand :

  • vous utilisez déjà TypeORM en backend et souhaitez le même style d'entités dans l'app,
  • your data model has many relations and you want relations: { posts: true } plutôt que des jointures écrites à la main,
  • votre équipe préfère les classes et les décorateurs aux objets de schéma.

Il est plus lourd qu'un query builder. Si la taille du bundle et le temps de démarrage sont prioritaires, ou si vous souhaitez utiliser @capgo/capacitor-fast-sql, consultez Kysely ou Drizzle plutôt.

Installer

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

TypeORM 1.x s'importe reflect-metadata lui-même, vous n'avez donc pas besoin d'un import séparé en haut de main.ts comme le montrent les anciens guides. Le plugin communautaire embarque SQLCipher sur iOS et Android, que vous chiffriez ou non, ce qui compte lorsque vous répondez aux questions de conformité à l'export de l'App Store.

Paramètres TypeScript

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

experimentalDecorators active les décorateurs legacy de TypeORM. useDefineForClassFields: false empêche les initialiseurs de champs de classe d’écraser les valeurs définies par TypeORM, ce qui compte quand votre target est ES2022 ou ultérieur.

Entités compatibles avec Vite et esbuild

La plupart des applications Capacitor sont compilées avec Vite, qui transpile TypeScript via esbuild. esbuild prend en charge les décorateurs mais n’implémente pas emitDecoratorMetadata, TypeORM ne peut donc pas lire les types de propriétés à l’exécution. Sans types explicites, vous obtenez ColumnTypeUndefinedError. Attribuez un type à chaque colonne :

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

Notes :

  • Relation<T> est un wrapper de type uniquement. Il évite les problèmes d’import circulaires entre author.ts et post.ts si vous compilez avec des métadonnées de décorateurs (par exemple dans les CLI ou les tests).
  • La colonne explicite authorId permet de filtrer et d’insérer par id sans charger l’auteur.
  • SQLite n’a pas de type booléen. TypeORM stocke les boolean colonnes en 0/1 et les reconvertit.

Configurer la 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 par option :

  • driver : celle du plugin SQLiteConnection. Le driver TypeORM l'utilise pour créer et ouvrir la connexion, puis active PRAGMA foreign_keys pour que onDelete: 'CASCADE' fonctionne.
  • database : le plugin ajoute un SQLite.db suffixe, le fichier est donc appSQLite.db.
  • mode: no-encryption, ou l'un des modes de chiffrement du plugin (secret, encryption). Consultez chiffrer SQLite dans Capacitor.
  • synchronize: false: en production, toujours. La synchro peut supprimer des colonnes pour aligner les entités, ce qui efface les données utilisateur sur l’appareil.
  • migrationsRun: false: la documentation du plugin demande cela avec le type capacitor. Exécutez les migrations vous-même juste après initialize(), comme illustré ci-dessous.
  • journalMode: 'WAL': optionnel. Meilleure concurrence entre les lectures et les écritures.
  • migrations: des références de classes, pas un glob. Il n'y a pas de scan du système de fichiers dans une WebView.

Les patterns glob comme entities: ['src/**/*.entity.ts'] ne fonctionnent pas dans l'app pour la même raison. Importez les classes.

Initialisez au démarrage

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

Appelez await initDatabase() avant que votre app n'affiche quoi que ce soit qui lise des données. transaction: 'each' enveloppe chaque migration dans sa propre transaction : en cas d'échec, les migrations précédentes restent appliquées et celle qui a échoué est annulée (rollback).

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() insère ou met à jour selon que la clé primaire est définie ou non, et renvoie l'entité avec son id généré. Pour les écritures en masse où vous n'avez pas besoin de récupérer les entités, insert() est plus rapide car il évite les recherches supplémentaires.

Query builder pour tout ce qui n'est pas trivial

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

Les jointures et agrégats, c'est là qu'un ORM vous fait gagner du temps sur mobile. Vérifiez le SQL généré avec .getSql() pendant le développement, et ajoutez des index sur les colonnes utilisées dans WHERE et JOIN.

Transactions

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

Utilisez le manager transmis au callback pour chaque requête dans la transaction. Si le callback lève une exception, TypeORM effectue un rollback.

Le pilote Capacitor utilise une seule connexion, il n’y a donc aucune isolation entre la transaction et les autres opérations : une requête effectuée via ds ou un autre repository pendant que la transaction est ouverte s’exécute sur la même connexion et est commitée ou annulée avec elle. Ne lancez pas de travail base de données sans rapport avant la fin de la transaction, gardez les transactions courtes et n’attendez jamais une requête réseau à l’intérieur.

Migrations : les générer avec une DataSource sqljs locale

Le CLI de TypeORM s’exécute sur votre ordinateur, pas sur le téléphone, il ne peut donc pas inspecter la base de données de l’appareil. La documentation du plugin indique que les migrations pour le type capacitor doivent être écrites à la main. Vous pouvez tout de même obtenir des migrations générées : les sqljs le driver utilise le même dialecte SQLite, il produit donc du SQL que l'appareil exécute tel quel.

1. Ajoutez une DataSource dédiée à CLI

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

Ajoutez .typeorm/ vers .gitignore. Ce fichier est une copie temporaire de « l'état du schéma après toutes les migrations existantes ».

2. Mettez à jour la base de données temporaire, puis lancez un 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 exécute directement la source de données et les entités TypeScript. La première commande applique les migrations existantes à la base de données temporaire. La seconde la compare à vos entités et écrit une nouvelle classe de migration, par exemple src/db/migrations/1790000123456-AddTags.ts. Ajoutez ensuite cette classe au migrations tableau dans data-source.ts.

Une migration initiale générée ressemble à ceci :

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

Lisez chaque migration générée avant de la déployer. Pour SQLite, TypeORM modifie souvent une table en la créant temporary_<table>, en copiant les lignes, en supprimant l'originale et en la renommant. C'est correct, mais sur une table volumineuse, cela prend du temps sur un téléphone : prévoyez donc un état de chargement.

Écrire des migrations à la main

Si vous écrivez vos migrations vous-même, commencez chaque instruction par un mot-clé en majuscules. Le query runner de capacitor analyse le premier mot, en tenant compte de la casse, pour déterminer comment appeler le plugin : BEGIN, COMMIT, ROLLBACK, CREATE, ALTER et DROP aller à execute, INSERT, UPDATE et DELETE aller à run, et tout le reste est envoyé à query. Un mot-clé en minuscules create table est envoyé comme une requête, ce qui n'est pas ce que vous voulez pour du DDL.

Migrations et live updates

Les classes de migration font partie de votre bundle JavaScript. Avec les live updates Capgo un changement de schéma atteint les utilisateurs sans passage par une revue du store. Un rollback peut ensuite placer d’anciennes code sur un schéma plus récent : privilégiez donc les migrations additives (nouvelles tables, nouvelles colonnes nullable) et supprimez les anciennes colonnes dans une release ultérieure.

Support web

Le plugin communautaire fonctionne sur le web via le jeep-sqlite composant web (sql.js persisté dans IndexedDB). Il est pratique pour le développement dans le navigateur :

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

Sur le web, la base de données est en mémoire et est écrite dans IndexedDB lorsque vous appelez sqliteConnection.saveToStore('app'). Appelez-l’après les écritures importantes, sinon les données seront perdues au rechargement.

Troubleshooting

Column type for Post#title is not defined and cannot be guessed. esbuild n’a pas émis de métadonnées. Ajoutez un type explicite au @Column décorateur.

Capacitor package has not been found installed. Le driver L’option est manquante ou undefined. Passez new SQLiteConnection(CapacitorSQLite).

Une erreur « connection already exists » au rechargement. L’initialisation s’est exécutée deux fois. Conservez l’unique ready promise et call checkConnectionsConsistency() avant initialize().

No metadata for "Post" was found. La classe d’entité ne figure pas dans le entities tableau, ou vous avez utilisé une chaîne glob. Importez la classe.

Données réinitialisées à chaque mise à jour de l’app en développement. synchronize: true est activé, ou les migrations suppriment et recréent les tables. Désactivez la sync et inspectez le SQL généré.

Les migrations ont été exécutées dans le CLI mais pas sur l’appareil. La nouvelle classe ne figure pas dans le migrations tableau dans data-source.ts. L’app ne scanne pas les dossiers.

Récapitulatif

TypeORM sur Capacitor repose sur trois éléments : le plugin SQLite communautaire comme driver, des entités avec des types de colonnes explicites, et des migrations générées sur un sqljs locale et importées en tant que classes. Une fois cela en place, les repositories, relations et transactions se comportent comme sur votre backend.

Pour une configuration plus légère sur @capgo/capacitor-fast-sql, consultez Kysely avec Capacitor. Pour les bases du plugin, commencez par Comment utiliser SQLite dans une application Capacitor. Lorsque le côté natif change, Build Capgo peut recompiler et signer les apps iOS et Android dans le cloud.

Live updates pour les apps Capacitor

Lorsqu'un bug de la couche web est en ligne, déployez le correctif via Capgo au lieu d'attendre des jours l'approbation des stores. Les utilisateurs reçoivent la mise à jour en arrière-plan, tandis que les changements natifs suivent le circuit de validation habituel.

Les derniers articles du blog

Capgo vous offre les meilleures informations pour créer une application mobile vraiment professionnelle.