Puedes usar extensiones de SQLite personalizadas en una app Capacitor, pero solo como code nativos compilados en la app: en iOS vinculas la extensión estáticamente y llamas a su función init en cada conexión, y en Android incluyes un build de SQLite que soporte la carga de extensiones y empaquetas la extensión como una librería nativa por arquitectura de CPU. Esta guía explica por qué difieren las plataformas, te ofrece una extensión funcional en C y muestra los cambios nativos exactos para @capgo/capacitor-fast-sql en Capacitor 8.
Antes de escribir C, asegúrate de que realmente lo necesitas. Muchas "extensiones" ya vienen integradas.
Qué es una extensión de SQLite
Una extensión es code nativo que registra nuevas funcionalidades en una conexión SQLite: funciones escalares (haversine_km(...)), funciones agregadas, collations, tablas virtuales y tokenizers FTS5. Cada extensión expone un único punto de entrada:
int sqlite3_<name>_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi);
SQLite lo llama una vez por conexión. Todo lo que registres solo existirá en esa conexión.
Motivos típicos para escribir una en una app móvil:
- un tokenizer FTS5 personalizado para un idioma que el
unicode61tokenizer integrado maneja mal, - una función matemática o geo que necesites en
WHEREyORDER BY(ordenamiento por distancia, scoring), - una collation que ordene los nombres como tus usuarios esperan,
- una extensión de búsqueda vectorial para recuperación en el dispositivo.
Revisa lo que ya viene integrado
Ejecuta esto a través de tu plugin en las versiones más antiguas de iOS y Android que soportas:
import { FastSQL } from '@capgo/capacitor-fast-sql';
const db = await FastSQL.connect({ database: 'probe' });
const [{ v }] = await db.query('SELECT sqlite_version() AS v');
const options = await db.query('PRAGMA compile_options');
const functions = await db.query('SELECT name FROM pragma_function_list ORDER BY name');
console.log(v, options.map((r) => r.compile_options));
Cosas para las que a menudo no necesitas una extensión:
| ¿Necesitas | Función integrada | Verificar |
|---|---|---|
| JSON en columnas | Funciones JSON (json_extract, ->>) |
Integradas desde SQLite 3.38. El SQLite del sistema en Android antiguo puede no incluirlas |
| Búsqueda de texto completo | FTS4, FTS5 | ENABLE_FTS4 / ENABLE_FTS5 en compile_options |
| Bounding boxes espaciales | R*Tree | ENABLE_RTREE |
sin, cos, sqrt, pow |
Funciones matemáticas | ENABLE_MATH_FUNCTIONS, SQLite 3.35+ |
Si la función está disponible en todos los targets, detente aquí. El build web de Fast SQL usa el SQLite Wasm oficial, que incluye JSON, FTS5 y funciones matemáticas.
Por qué iOS y Android funcionan distinto
iOS. El sistema libsqlite3 está construido con SQLITE_OMIT_LOAD_EXTENSION, así que load_extension() no existe. Las reglas de la App Store también prohíben descargar y ejecutar nuevos code (guía 2.5.2), por lo que cargar un .dylib en tiempo de ejecución no es una opción. La extensión debe compilarse en el binario de la app. Muchos tutoriales entonces llaman a sqlite3_auto_extension() al inicio. No te fíes de eso: en el SDK de iOS que viene con Xcode, sqlite3.h lo declara con API_DEPRECATED("Process-global auto extensions are not supported on Apple platforms", ...). El enfoque más fiable es llamar a tu función de inicialización en cada handle de conexión justo después de abrirlo.
Android. El SQLite dentro del framework de Android (android.database.sqlite) no tiene API para cargar extensiones. Necesitas un build de SQLite incluido en tu app que sí lo permita. requery/sqlite-android es un reemplazo directo de las clases del framework, incluye una versión actual de SQLite (con FTS5 y JSON) y soporta extensiones cargables mediante SQLiteCustomExtension. Tu extensión se convierte en un .so archivo por ABI dentro del APK.
Web. La compilación Wasm estándar de SQLite no puede cargar code nativos. Necesitarías una compilación Wasm personalizada de SQLite con tu extensión compilada.
Lo que los plugins soportan hoy
| iOS | Android | Web | |
|---|---|---|---|
@capgo/capacitor-fast-sql 8.2 |
Sin hook, no se necesita patch | Usa SQLite del framework, se necesita patch | SQLite Wasm estándar |
@capacitor-community/sqlite 8.1 |
loadExtension() se declara en el JS API pero no se implementa de forma nativa |
Igual | No implementado |
Así que, por ahora, añadir una extensión implica un pequeño parche nativo. Es un cambio acotado, y Bun puede mantenerlo aplicado entre instalaciones.
Paso 1: escribir la extensión
Una función de distancia es un buen ejemplo: útil, autocontenida y fácil de verificar. Guárdala como geo.c:
#include <math.h>
#include <stddef.h>
#include "sqlite3ext.h"
SQLITE_EXTENSION_INIT1
static void haversine_km(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
(void)argc;
for (int i = 0; i < 4; i++) {
if (sqlite3_value_type(argv[i]) == SQLITE_NULL) {
sqlite3_result_null(ctx);
return;
}
}
const double earth_radius_km = 6371.0088;
const double to_rad = M_PI / 180.0;
double lat1 = sqlite3_value_double(argv[0]) * to_rad;
double lon1 = sqlite3_value_double(argv[1]) * to_rad;
double lat2 = sqlite3_value_double(argv[2]) * to_rad;
double lon2 = sqlite3_value_double(argv[3]) * to_rad;
double dlat = lat2 - lat1;
double dlon = lon2 - lon1;
double a = sin(dlat / 2) * sin(dlat / 2) +
cos(lat1) * cos(lat2) * sin(dlon / 2) * sin(dlon / 2);
sqlite3_result_double(ctx, 2 * earth_radius_km * asin(sqrt(a)));
}
int sqlite3_geo_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {
SQLITE_EXTENSION_INIT2(pApi);
(void)pzErrMsg;
return sqlite3_create_function_v2(
db, "haversine_km", 4,
SQLITE_UTF8 | SQLITE_DETERMINISTIC | SQLITE_INNOCUOUS,
NULL, haversine_km, NULL, NULL, NULL);
}
El mismo archivo se compila de ambas formas:
- Como extensión cargable (Android):
SQLITE_EXTENSION_INIT1/2rutea cada llamada de SQLite a través delpApipuntero que SQLite pasa, para que el.sono enlaza contra SQLite. - Estáticamente, con
-DSQLITE_CORE(iOS): las macros se convierten en no-ops y las llamadas van directamente a la biblioteca de SQLite que enlaza la app.
SQLITE_DETERMINISTIC permite a SQLite usar la función en índices sobre expresiones. SQLITE_INNOCUOUS la marca como segura para usar en vistas y triggers.
Compilé este archivo de ambas formas en macOS: estáticamente con -DSQLITE_CORE y llamé a sqlite3_geo_init(db, NULL, NULL) en una conexión SQLite del sistema, y como una librería cargable mediante load_extension. Ambos devuelven unos 343.6 km de París a Londres.
Paso 2: iOS, compilar en la app y registrar por conexión
Añade el código fuente al target de la app
- En Xcode, arrastra
geo.cdentro delAppgrupo y marca elAppobjetivo. - Xcode ofrece crear un bridging header. Acepta o crea
App/App-Bridging-Header.hy configúralo en Ajustes de compilación > Swift Compiler - General > Objective-C Bridging Header. - En Fases de compilación > Compilar fuentes, haz doble clic
geo.cy añade el flag por archivo-DSQLITE_CORE.
El SDK de iOS incluye ambos sqlite3.h y sqlite3ext.h, así que no hace falta añadir headers.
App-Bridging-Header.h:
#include <sqlite3.h>
int sqlite3_geo_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi);
Parchea Fast SQL para exponer un hook abierto
El plugin abre conexiones en su propio módulo, por lo que necesita un hook que tu app pueda configurar. Bun puede parchear una dependencia y reaplicar el parche en cada instalación:
bun patch @capgo/capacitor-fast-sql
Editar node_modules/@capgo/capacitor-fast-sql/ios/Sources/CapgoCapacitorFastSqlPlugin/SQLDatabase.swift. Añade un hook público en el nivel superior del archivo:
public enum FastSqlExtensions {
/// Called with every new connection handle right after it opens.
/// Return SQLITE_OK (0) on success.
public static var onOpen: ((OpaquePointer) -> Int32)?
}
Luego, en init(...), después del bloque de cifrado y antes de try applyPerformanceOptions(...), invócalo:
if let hook = FastSqlExtensions.onOpen {
let rc = hook(db)
if rc != SQLITE_OK {
throw SQLError.openFailed(message: "Extension init failed with code \(rc)")
}
}
Guarda el patch:
bun patch --commit node_modules/@capgo/capacitor-fast-sql
Bun escribe un archivo en patches/ y lo registra en package.json en patchedDependencies. Haz commit de ambos. Nuestra guía sobre cómo parchear un plugin de Capacitor cubre este flujo de trabajo con más detalle.
Registra la extensión al iniciar
En ios/App/App/AppDelegate.swift, importa el módulo del plugin y establece el hook en application(_:didFinishLaunchingWithOptions:):
import UIKit
import Capacitor
import CapgoCapacitorFastSql // CocoaPods module name. With SPM: import CapgoCapacitorFastSqlPlugin
// Inside the existing AppDelegate class:
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FastSqlExtensions.onOpen = { db in
sqlite3_geo_init(db, nil, nil)
}
return true
}
Como el hook se ejecuta dentro de la ruta de apertura del plugin, cada conexión obtiene la función, incluidas las abiertas tras una recarga de WebView. Al configurarlo en didFinishLaunching garantiza que esté listo antes de que JavaScript pueda llamar a connect().
builds de SQLCipher. Si usas el subspec SQLCipher del plugin, la conexión pertenece al SQLite de SQLCipher, mientras que tu -DSQLITE_CORE archivo puede enlazar contra los símbolos de la librería del sistema. Prueba esta combinación explícitamente antes de publicar, o mantén los datos que dependen de la extensión en una base de datos sin cifrar.
Paso 3: Android, incluye un SQLite que pueda cargar extensiones
Compila la extensión para cada ABI
Necesitas el Android NDK (instálalo desde el SDK Manager de Android Studio) y sqlite3ext.h además de sqlite3.h del la amalgamation de SQLite junto a geo.c.
export NDK=$HOME/Library/Android/sdk/ndk/<version>
export TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64 # linux-x86_64 on Linux
export API=24 # your minSdkVersion
for pair in \
aarch64-linux-android:arm64-v8a \
armv7a-linux-androideabi:armeabi-v7a \
x86_64-linux-android:x86_64 \
i686-linux-android:x86
do
triple=${pair%%:*}
abi=${pair##*:}
mkdir -p android/app/src/main/jniLibs/$abi
$TOOLCHAIN/bin/clang --target=$triple$API \
-shared -fPIC -O2 \
-Wl,-z,max-page-size=16384 \
-o android/app/src/main/jniLibs/$abi/libgeo.so \
geo.c -lm
done
-Wl,-z,max-page-size=16384 alinea la biblioteca para dispositivos con páginas de memoria de 16 KB. Google Play exige soporte de 16 KB para apps dirigidas a Android 15 o superior, ver tamaño de página de 16 KB y plugins de Capacitor. Si tu app solo se distribuye en dispositivos de 64 bits, puedes omitir las dos ABIs de 32 bits.
La biblioteca se llama libgeo.so, so SQLite’s default entry point name is sqlite3_geo_init, que coincide con la función en C.
Extraer bibliotecas nativas en tiempo de instalación
Las versiones recientes de Android Gradle Plugin mantienen .so archivos sin comprimir dentro del APK en lugar de extraerlos. SQLite carga extensiones con una ruta de archivo, así que pide la extracción en android/app/build.gradle:
android {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
Agregar requery sqlite-android
En android/build.gradle (raíz), agregar JitPack:
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
Parchear Fast SQL para usarlo
Abre la misma sesión de parche de Bun (bun patch @capgo/capacitor-fast-sql) y edita dos archivos.
node_modules/@capgo/capacitor-fast-sql/android/build.gradle, en dependencies:
implementation 'com.github.requery:sqlite-android:3.49.0'
Usa la última versión que JitPack muestre como compilada para el repositorio.
node_modules/@capgo/capacitor-fast-sql/android/src/main/java/app/capgo/capacitor/fastsql/SQLDatabase.java: sustituye las importaciones del framework por las de requery y ábrelo mediante una configuración que incluya las extensiones.
// Replace:
// import android.database.sqlite.SQLiteDatabase;
// import android.database.sqlite.SQLiteStatement;
import io.requery.android.database.sqlite.SQLiteCustomExtension;
import io.requery.android.database.sqlite.SQLiteDatabase;
import io.requery.android.database.sqlite.SQLiteDatabaseConfiguration;
import io.requery.android.database.sqlite.SQLiteStatement;
public class SQLDatabase implements DatabaseConnection {
/** Set from MainActivity before the first connection opens. */
public static final java.util.List<SQLiteCustomExtension> extensions =
new java.util.concurrent.CopyOnWriteArrayList<>();
private SQLiteDatabase db;
private volatile boolean inTransaction = false;
public SQLDatabase(String path, boolean walMode, boolean performancePresets) throws Exception {
SQLiteDatabaseConfiguration config = new SQLiteDatabaseConfiguration(
path, SQLiteDatabase.CREATE_IF_NECESSARY);
config.customExtensions.addAll(extensions);
this.db = SQLiteDatabase.openDatabase(config, null, null);
SQLPerformanceConfig.apply(db::execSQL, walMode, performancePresets);
}
// ... the rest of the class stays as it is
}
las clases de requery imitan el framework API (rawQuery, compileStatement, execSQL, transacciones), por lo que el resto del archivo se compila sin cambios. android.database.Cursor se queda como está. Haz commit del parche con bun patch --commit node_modules/@capgo/capacitor-fast-sql.
Esto solo cambia la ruta sin cifrar. Las conexiones cifradas usan las propias clases de SQLCipher, que este parche no toca.
Registra la extensión en MainActivity
package com.example.app;
import android.os.Bundle;
import app.capgo.capacitor.fastsql.SQLDatabase;
import com.getcapacitor.BridgeActivity;
import io.requery.android.database.sqlite.SQLiteCustomExtension;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
String path = getApplicationInfo().nativeLibraryDir + "/libgeo.so";
SQLDatabase.extensions.add(new SQLiteCustomExtension(path, "sqlite3_geo_init"));
super.onCreate(savedInstanceState);
}
}
Registra antes de super.onCreate para que la lista se rellene antes de que el WebView pueda ejecutar JavaScript.
Android sin C: funciones Java
Si solo necesitas una función escalar, requery puede registrar una escrita en Java, sin necesidad de NDK. Dentro del SQLDatabase constructor parcheado, después de abrir:
db.addFunction("haversine_km", 4, (args, result) -> {
double r = 6371.0088;
double lat1 = Math.toRadians(args.getDouble(0));
double lat2 = Math.toRadians(args.getDouble(2));
double dlat = lat2 - lat1;
double dlon = Math.toRadians(args.getDouble(3) - args.getDouble(1));
double a = Math.pow(Math.sin(dlat / 2), 2)
+ Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2);
result.set(2 * r * Math.asin(Math.sqrt(a)));
});
Esto es más lento que C en consultas pesadas porque cada llamada cruza JNI, pero es mucho más sencillo de mantener. Los tokenizers de FTS5 y las tablas virtuales siguen necesitando C.
Paso 4: úsalo desde JavaScript
Después de bunx cap sync y una recompilación nativa:
import { FastSQL } from '@capgo/capacitor-fast-sql';
const db = await FastSQL.connect({ database: 'places' });
await db.execute(`CREATE TABLE IF NOT EXISTS places (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL
)`);
const nearby = await db.query(
`SELECT id, name, haversine_km(?, ?, lat, lon) AS km
FROM places
WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?
ORDER BY km
LIMIT 20`,
[userLat, userLon, userLat - 0.5, userLat + 0.5, userLon - 0.7, userLon + 0.7],
);
El BETWEEN box permite que SQLite use un índice en (lat, lon) para reducir los candidatos antes de que la función calcule distancias exactas. Sin él, la función se ejecuta en cada fila.
Un fallback en JavaScript para la web
La compilación web no puede cargar tu code de C, así que detéctalo y usa un fallback:
import { Capacitor } from '@capacitor/core';
export const hasGeoExtension = Capacitor.isNativePlatform();
// On the web: query the bounding box, then sort in JS with the same formula.
Tokenizadores FTS5 personalizados
El mismo mecanismo registra tokenizadores FTS5. En tu función de init, obtén el fts5_api puntero con el SELECT fts5(?1) patrón de la documentación de FTS5, luego llama a xCreateTokenizer. Dos requisitos adicionales:
- La biblioteca SQLite debe incluir FTS5. La compilación de requery lo hace. En iOS, comprueba
PRAGMA compile_optionsparaENABLE_FTS5. - Cada conexión que escriba o lea de la tabla FTS debe tener el tokenizer registrado, porque FTS5 guarda el nombre del tokenizer en la definición de la tabla. Por eso importa el hook por conexión.
CREATE VIRTUAL TABLE docs USING fts5(title, body, tokenize = 'my_tokenizer');
Publicación y mantenimiento
- Requiere un rebuild nativo. Las extensiones, los parches y los cambios en Gradle son nativos. Necesitan un nuevo build para App Store y Play Store. Build de Capgo puedes compilar y firmar ambas en la nube. Desde abril de 2026, las cargas de iOS deben compilarse con Xcode 26 o posterior.
- JavaScript todavía puede ir rápido. SQL que llama a
haversine_kmvive en tu web bundle y puede publicarse con actualizaciones en vivo, siempre que el build nativo que contiene la extensión ya esté instalado. Si los usuarios aún pueden estar usando un binario antiguo, condiciona el nuevo SQL a una comprobación de versión nativa. - Actualizaciones de plugin. Cuando actualizas Fast SQL, Bun vuelve a aplicar el parche. Si las líneas parcheadas se movieron, la instalación falla de forma explícita en lugar de descartar silenciosamente tu hook. Recrea el parche en la nueva versión.
Solución de problemas
no such function: haversine_km en iOS. El hook no se configuró antes de connect(), o la base de datos se abrió antes de didFinishLaunching que se ejecutara. Añade logs dentro del hook para confirmar que se llama.
Crash dentro de sqlite3_geo_init en iOS. -DSQLITE_CORE falta, por lo que el archivo se compiló como una extensión cargable que desreferencia el pApi puntero, que está nil aquí. Añade el flag por archivo en Compile Sources.
no such function en Android. El parcheado SQLDatabase sigue importando android.database.sqlite.SQLiteDatabase, o la extensión se añadió a la lista tras la primera conexión. Registra logs SQLDatabase.extensions.size() al conectar.
dlopen failed: library "…/libgeo.so" not found. Falta la librería para el ABI del dispositivo (la mayoría de los teléfonos son arm64-v8a), o las librerías nativas no se extrajeron. Comprueba useLegacyPackaging e inspecciona el APK con el APK Analyzer de Android Studio.
undefined symbol: sqlite3_geo_init. El nombre del entry point que se pasa a SQLiteCustomExtension no coincide con la función C, o la función fue declarada static.
Play Console advierte sobre la alineación de 16 KB. Recompila los .so archivos con -Wl,-z,max-page-size=16384 y un NDK reciente, y actualiza requery a una versión compilada con alineación de 16 KB.
La función funciona en Android pero no en el simulador de iOS. Asegúrate de que geo.c es miembro de App destino, no solo presente en el árbol de archivos.
Resumen
| Plataforma | Cómo se incorpora la extensión | Dónde se registra |
|---|---|---|
| iOS | Compilado en la app con -DSQLITE_CORE |
Plugin hook que llama a sqlite3_geo_init en cada nueva conexión |
| Android | .so por ABI en jniLibs, cargado por requery sqlite-android |
SQLiteCustomExtension lista leída al abrir cada conexión |
| Web | No soportado por SQLite Wasm estándar | Fallback en JavaScript |
Comienza revisando las funciones integradas. Si aún necesitas una extensión, escríbela una vez en C, mantén los parches nativos pequeños y prueba en los dispositivos más antiguos que soportas. Para la configuración general del plugin, consulta Cómo usar SQLite en una app de Capacitor.