Saltar al contenido principal

Cómo usar TypeORM con Capacitor y SQLite

Usa TypeORM con Capacitor y SQLite en iOS y Android: configuración de DataSource, entidades que funcionan con Vite, repositorios, transacciones y migraciones generadas.

Logotipo de Capacitor para una guía sobre el uso de TypeORM con Capacitor y SQLite

TypeORM funciona en una app de Capacitor a través de su capacitor driver integrado, que ejecuta cada consulta en @capacitor-community/sqlite. Configuras un DataSource con el plugin SQLiteConnection, define entidades con decoradores y usa repositorios y transacciones como lo harías en un servidor. Esta guía cubre la configuración completa en TypeORM 1.x y Capacitor 8, incluyendo dos cosas que la mayoría de los tutoriales omiten: tipos de columna explícitos para Vite y cómo generar migraciones aunque el CLI no pueda comunicarse con un teléfono.

Cuándo conviene usar TypeORM

TypeORM es un ORM data mapper con entidades, relaciones, repositorios y un sistema de migraciones. En mobile tiene sentido cuando:

  • ya usas TypeORM en el backend y quieres el mismo estilo de entidades en la app,
  • tu modelo de datos tiene muchas relaciones y quieres relations: { posts: true } en lugar de escribir joins a mano,
  • tu equipo prefiere clases y decoradores en lugar de objetos de esquema.

Es más pesado que un query builder. Si el tamaño del bundle y el tiempo de arranque son lo más importante, o quieres usar @capgo/capacitor-fast-sql, mira Kysely o Drizzle en su lugar.

Instalar

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

Imports de TypeORM 1.x reflect-metadata por sí mismo, así que no necesitas un import aparte al principio de main.ts como muestran las guías antiguas. El plugin de la comunidad incluye SQLCipher en iOS y Android tanto si cifras como si no, lo cual importa cuando respondes a las preguntas de cumplimiento de exportación de App Store.

Configuración de TypeScript

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

experimentalDecorators habilita los decoradores legacy de TypeORM. useDefineForClassFields: false evita que los inicializadores de campos de clase sobrescriban los valores que TypeORM establece, algo importante cuando tu target es ES2022 o posterior.

Entidades que funcionan con Vite y esbuild

La mayoría de las aplicaciones de Capacitor se construyen con Vite, que compila TypeScript con esbuild. esbuild admite decoradores pero no implementa emitDecoratorMetadata, por lo que TypeORM no puede leer los tipos de propiedades en tiempo de ejecución. Sin tipos explícitos obtienes ColumnTypeUndefinedError. Asigna un tipo a cada columna:

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

Notas:

  • Relation<T> es un wrapper de solo tipos. Evita problemas de importación circular entre author.ts y post.ts si alguna vez compilas con metadatos de decoradores (por ejemplo, en CLI o en tests).
  • La columna explícita authorId permite filtrar e insertar por id sin cargar el autor.
  • SQLite no tiene tipo booleano. TypeORM almacena boolean las columnas como 0/1 y las convierte de vuelta.

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

Opción por opción:

  • driver: el plugin SQLiteConnection. El driver de TypeORM lo usa para crear y abrir la conexión, y luego activa PRAGMA foreign_keys para que onDelete: 'CASCADE' funciona.
  • database: el plugin añade un SQLite.db sufijo, así que el archivo es appSQLite.db.
  • mode: no-encryption, o uno de los modos de cifrado del plugin (secret, encryption). Consulta cifrando SQLite en Capacitor.
  • synchronize: false: en producción, siempre. Sync puede eliminar columnas para coincidir con las entidades, lo que borra datos de usuario en el dispositivo.
  • migrationsRun: false: la documentación del plugin lo pide con el tipo capacitor. Ejecuta las migraciones tú mismo justo después initialize(), como se muestra abajo.
  • journalMode: 'WAL': opcional. Mejor concurrencia entre lecturas y escrituras.
  • migrations: referencias de clase, no un glob. No hay escaneo del sistema de archivos en un WebView.

Patrones glob como entities: ['src/**/*.entity.ts'] no funcionan en la app por la misma razón. Importa las clases.

Inicializar al arrancar

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

Llama a await initDatabase() antes de que tu app renderice cualquier cosa que lea datos. transaction: 'each' envuelve cada migración en su propia transacción, así que si falla, las anteriores se mantienen y la fallida se revierte.

Repositorios: 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() inserta o actualiza según si la clave primaria está definida, y devuelve la entidad con su id generado. Para escrituras masivas donde no necesites las entidades, insert() es más rápido porque evita búsquedas adicionales.

Query builder para cualquier consulta compleja

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

Los joins y agregados son donde un ORM te ahorra tiempo en móvil. Revisa el SQL generado con .getSql() mientras desarrollas, y añade índices a las columnas usadas en WHERE y JOIN.

Transacciones

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

Usa la manager se pasa al callback para cada consulta dentro de la transacción. Si el callback lanza una excepción, TypeORM hace rollback.

El driver Capacitor usa una sola conexión, así que no hay aislamiento entre la transacción y el resto del trabajo: una consulta hecha a través de ds o otro repository mientras la transacción está abierta se ejecuta en la misma conexión y se confirma o revierte con ella. No inicies trabajo de base de datos no relacionado hasta que la transacción termine, mantén las transacciones cortas y nunca esperes una solicitud de red dentro de una.

Migraciones: genéralas con un DataSource sqljs local

El CLI de TypeORM se ejecuta en tu computadora, no en el teléfono, por lo que no puede inspeccionar la base de datos del dispositivo. La documentación del plugin indica que las migraciones para el tipo capacitor deben escribirse a mano. Aun así, puedes obtener migraciones generadas: el sqljs driver usa el mismo dialecto de SQLite, por lo que genera SQL que el dispositivo ejecuta sin cambios.

1. Añade un DataSource exclusivo de 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'],
});

Añadir .typeorm/ a .gitignore. Ese archivo es una copia de trabajo de «cómo se ve el esquema después de todas las migraciones existentes».

2. Actualiza la base de datos de trabajo y luego compara

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 ejecuta directamente el data source y las entidades de TypeScript. El primer comando aplica las migraciones existentes a la base de datos de trabajo. El segundo la compara con tus entidades y escribe una nueva clase de migración, por ejemplo src/db/migrations/1790000123456-AddTags.ts. Luego añade esa clase a la migrations el array en data-source.ts.

Una migración inicial generada se ve así:

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

Lee cada migración generada antes de publicarla. Para SQLite, TypeORM suele modificar una tabla creando temporary_<table>, copiando las filas, eliminando la original y renombrando. Eso es correcto, pero en una tabla grande tarda en un teléfono, así que ejecútalo con un estado de carga.

Escribir migraciones a mano

Si escribes las migraciones tú mismo, empieza cada sentencia con una palabra clave en mayúsculas. El query runner de capacitor analiza la primera palabra, distinguiendo entre mayúsculas y minúsculas, para decidir cómo llamar al plugin: BEGIN, COMMIT, ROLLBACK, CREATE, ALTER y DROP van a execute, INSERT, UPDATE y DELETE van a run, y todo lo demás va a query. Una minúscula create table se envía como una query, que no es lo que quieres para DDL.

Migraciones y live updates

Las clases de migración forman parte de tu bundle de JavaScript. Con actualizaciones Live Update de Capgo un cambio de esquema llega a los usuarios sin pasar por la revisión de la tienda. Un rollback puede dejar versiones anteriores de code sobre un esquema más nuevo, así que mejor usa migraciones aditivas (tablas nuevas, nuevas columnas nullable) y elimina las columnas antiguas en una versión posterior.

Soporte web

El plugin de la comunidad funciona en web a través del jeep-sqlite componente web (sql.js persistido en IndexedDB). Es útil para desarrollo en el navegador:

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

En web, la base de datos vive en memoria y se escribe en IndexedDB cuando llamas a sqliteConnection.saveToStore('app'). Llámalo después de las escrituras que te importan, o los datos se perderán al recargar.

Solución de problemas

Column type for Post#title is not defined and cannot be guessed. esbuild no emitió metadatos. Añade un tipo explícito al @Column decorador.

Capacitor package has not been found installed. El driver la opción falta o está indefinida. Pasa new SQLiteConnection(CapacitorSQLite).

Un error de “connection already exists” al recargar. La inicialización se ejecutó dos veces. Mantén la única ready promesa y llama a checkConnectionsConsistency() antes initialize().

No metadata for "Post" was found. La clase de entidad no está en el entities array, o usaste una cadena glob. Importa la clase.

Reinicio de datos tras cada actualización de la app en desarrollo. synchronize: true está activado, o las migraciones eliminan y recrean las tablas. Desactiva sync e inspecciona el SQL generado.

Las migraciones se ejecutaron en CLI pero no en el dispositivo. La nueva clase no está en migrations el array en data-source.ts. La app no escanea carpetas.

Resumen

TypeORM en Capacitor consta de tres partes: el plugin de la comunidad SQLite como driver, entidades con tipos de columna explícitos y migraciones generadas localmente sqljs local e importadas como clases. Con esto listo, los repositorios, relaciones y transacciones funcionan igual que en tu backend.

Para una configuración más ligera en @capgo/capacitor-fast-sql, consulta Kysely con Capacitor. Para los conceptos básicos del plugin, empieza con Cómo usar SQLite en una app de Capacitor. Cuando cambie el lado nativo, Build de Capgo puede reconstruir y firmar las apps de iOS y Android en la nube.

Live updates para apps Capacitor

Cuando un bug de la capa web está en producción, publica el fix a través de Capgo en lugar de esperar días a la aprobación de la app store. Los usuarios reciben la actualización en segundo plano mientras los cambios nativos siguen el flujo de revisión habitual.

Lo último de nuestro blog

Capgo te da la información clave que necesitas para crear una app móvil realmente profesional.