TypeORM funziona in un'app Capacitor tramite il suo capacitor driver integrato, che esegue ogni query su @capacitor-community/sqlite. Configuri un DataSource con il plugin SQLiteConnection, definisci entità con i decorator e usa repository e transazioni come faresti su un server. Questa guida copre la configurazione completa su TypeORM 1.x e Capacitor 8, inclusi i due aspetti che la maggior parte dei tutorial ignora: i tipi di colonna espliciti per Vite e come generare le migration, anche se CLI non può comunicare con uno smartphone.
Quando TypeORM è la scelta giusta
TypeORM è un ORM data-mapper con entità, relazioni, repository e un sistema di migration. Ha senso su mobile quando:
- usate già TypeORM sul backend e volete lo stesso stile di entità nell'app,
- il vostro modello dati ha molte relazioni e volete
relations: { posts: true }invece di join scritti a mano, - il vostro team preferisce classi e decorator rispetto a oggetti schema.
È più pesante di un query builder. Se la dimensione del bundle e il tempo di avvio sono le priorità, o volete usare @capgo/capacitor-fast-sql, guarda Kysely oppure Drizzle invece.
Installa
bun add typeorm @capacitor-community/sqlite
bunx cap sync
Import di TypeORM 1.x reflect-metadata se stesso, quindi non serve un import separato in cima a main.ts come mostrato nelle vecchie guide. Il plugin della community include SQLCipher su iOS e Android a prescindere dalla crittografia, un dettaglio importante per le domande di conformità all'esportazione dell'App Store.
Impostazioni TypeScript
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}
experimentalDecorators abilita i decorator legacy di TypeORM. useDefineForClassFields: false impedisce agli inizializzatori dei campi di classe di sovrascrivere i valori impostati da TypeORM, il che è fondamentale quando il tuo target è ES2022 o successivo.
Entity compatibili con Vite ed esbuild
La maggior parte delle app Capacitor è costruita con Vite, che compila TypeScript con esbuild. esbuild supporta i decorator ma non implementa emitDecoratorMetadata, quindi TypeORM non può leggere i tipi delle proprietà a runtime. Senza tipi espliciti ottieni ColumnTypeUndefinedError. Assegna un tipo a ogni colonna:
// 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>;
}
Note:
Relation<T>è un wrapper solo per i tipi. Evita problemi di import circolari traauthor.tsepost.tsse mai compili con i metadati dei decorator (ad esempio in CLI o nei test).- La colonna esplicita
authorIdpermette di filtrare e inserire per id senza caricare l'autore. - SQLite non ha un tipo booleano. TypeORM memorizza
booleanle colonne come0/1e li converte di nuovo.
Configura il 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'],
});
Opzione per opzione:
driver: del pluginSQLiteConnection. Il driver di TypeORM lo usa per creare e aprire la connessione, poi attivaPRAGMA foreign_keysquindionDelete: 'CASCADE'funziona.database: il plugin aggiunge unSQLite.dbsuffisso, quindi il file èappSQLite.db.mode:no-encryption, o una delle modalità di crittografia del plugin (secret,encryption). Vedi criptare SQLite in Capacitor.synchronize: false: in produzione, sempre. Sync può eliminare colonne per allinearsi alle entità, cancellando i dati utente sul dispositivo.migrationsRun: false: la documentazione del plugin richiede questo con il tipo capacitor. Esegui le migrazioni tu stesso subito dopoinitialize(), come mostrato di seguito.journalMode: 'WAL': opzionale. Migliore concorrenza tra letture e scritture.migrations: riferimenti alle classi, non un glob. Non esiste uno scan del file system in un WebView.
I glob pattern come entities: ['src/**/*.entity.ts'] non funzionano nell'app per lo stesso motivo. Importa le classi.
Inizializza all'avvio
// 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;
}
Chiama await initDatabase() prima che l'app renderizzi qualsiasi cosa che legga dati. transaction: 'each' avvolge ogni migrazione nella sua transazione, quindi un errore lascia le migrazioni precedenti applicate e quella fallita viene annullata.
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() inserisce o aggiorna a seconda che la chiave primaria sia impostata, e restituisce l'entità con il suo id generato. Per scritture in blocco dove non serve restituire le entità, insert() è più veloce perché evita le ricerche extra.
Query builder per qualsiasi operazione non banale
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 }>();
Joins e aggregazioni sono dove un ORM ti fa risparmiare tempo su mobile. Controlla l'SQL generato con .getSql() durante lo sviluppo e aggiungi indici per le colonne usate in WHERE e JOIN.
Transazioni
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 il manager passata al callback per ogni query all'interno della transazione. Se il callback lancia un errore, TypeORM esegue il rollback.
Il driver Capacitor usa una singola connessione, quindi non c'è isolamento tra la transazione e le altre operazioni: una query eseguita tramite ds o un altro repository mentre la transazione è aperta gira sulla stessa connessione e ne segue il commit o il rollback. Non avviare altre operazioni sul database finché la transazione non è conclusa, tieni le transazioni brevi e non aspettare mai una richiesta di rete al loro interno.
Migration: generarle con un DataSource sqljs locale
Il CLI di TypeORM gira sul tuo computer, non sul telefono, quindi non può ispezionare il database del dispositivo. La documentazione del plugin dice che le migration per il tipo capacitor vanno scritte a mano. Puoi comunque ottenere migration generate: quelle di TypeORM sqljs il driver usa lo stesso dialetto SQLite, quindi genera SQL che il dispositivo esegue senza modifiche.
1. Aggiungi un DataSource solo per 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'],
});
Aggiungi .typeorm/ a .gitignore. Quel file è una copia di lavoro di "come appare lo schema dopo tutte le migrazioni esistenti".
2. Aggiorna il database di lavoro, poi confronta
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 esegue direttamente il data source e le entità TypeScript. Il primo comando applica le migrazioni esistenti al database di lavoro. Il secondo lo confronta con le tue entità e scrive una nuova classe di migrazione, ad esempio src/db/migrations/1790000123456-AddTags.ts. Poi aggiungi quella classe all' migrations array in data-source.ts.
Una migrazione iniziale generata è così:
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"`);
}
}
Leggi ogni migrazione generata prima di rilasciarla. Per SQLite, TypeORM spesso modifica una tabella creando temporary_<table>, copiando le righe, eliminando l'originale e rinominando. È corretto, ma su una tabella grande richiede tempo su uno smartphone, quindi eseguila con uno stato di caricamento.
Scrivere le migrazioni a mano
Se scrivi le migrazioni manualmente, inizia ogni istruzione con una keyword in maiuscolo. Il query runner di capacitor analizza la prima parola, rispettando il case, per decidere come chiamare il plugin: BEGIN, COMMIT, ROLLBACK, CREATE, ALTER e DROP vanno in execute, INSERT, UPDATE e DELETE vanno in run, e tutto il resto va in query. Una keyword in minuscolo create table viene inviata come query, che non è quello che vuoi per le DDL.
Migrazioni e live updates
Le classi di migrazione fanno parte del tuo bundle JavaScript. Con Live updates di Capgo Una modifica allo schema arriva agli utenti senza revisione dello store. Un rollback può quindi applicare vecchi code a uno schema più recente: preferisci migrazioni additive (nuove tabelle, nuove colonne nullable) e rimuovi le vecchie colonne in una release successiva.
Supporto web
Il plugin della community gira sul web tramite il jeep-sqlite web component (sql.js salvato su IndexedDB). È utile per lo sviluppo nel browser:
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');
}
Sul web il database risiede in memoria e viene scritto su IndexedDB quando chiami sqliteConnection.saveToStore('app'). Chiamalo dopo le scritture che ti interessano, altrimenti i dati andranno persi al reload.
Risoluzione problemi
Column type for Post#title is not defined and cannot be guessed. esbuild non ha emesso metadati. Aggiungi un tipo esplicito al @Column decorator.
Capacitor package has not been found installed. Il driver l'opzione è mancante o undefined. Passa new SQLiteConnection(CapacitorSQLite).
Un errore "connection already exists" al reload. Inizializzazione eseguita due volte. Mantieni quella singola ready promise e chiama checkConnectionsConsistency() prima initialize().
No metadata for "Post" was found. La classe entity non è presente nell' entities array, oppure hai usato una glob string. Importa la classe.
I dati vengono resettati ad ogni aggiornamento dell'app durante lo sviluppo. synchronize: true è attiva, oppure le migrazioni eliminano e ricreano le tabelle. Disattiva la sync e controlla l'SQL generato.
Le migrazioni sono state eseguite in CLI, ma non sul dispositivo. La nuova classe non è presente nell' migrations array in data-source.ts. L'app non scansiona le cartelle.
In sintesi
TypeORM su Capacitor si compone di tre elementi: il plugin SQLite della community come driver, le entità con tipi di colonna espliciti e le migrazioni generate localmente sqljs database e importate come classi. Una volta configurate, repository, relazioni e transazioni si comporteranno esattamente come sul backend.
Per un setup più leggero su @capgo/capacitor-fast-sql, vedi Kysely con Capacitor. Per le basi del plugin, parti da Come usare SQLite in un'app Capacitor. Quando cambia il lato nativo, Build Capgo può ricompilare e firmare le app iOS e Android nel cloud.