SQLite3 en JavaScript con WebAssembly y OPFS

Anteriormente te he hablado de SQLite en el navegador web, ya sea con Svelte o JavaScript puro. Hoy voy a mostrarte otra cosa: cómo invocar a SQLite3 desde WASM o WebAssembly.

Al final vamos a estar invocando la API de SQLite3 de JS desde WebAssembly. Te enseñaré cómo es mi entorno de trabajo y cómo usar SQLite3 con WebAssembly.

Lecturas recomendadas

Aquí solo te voy a enseñar a invocar las APIs de SQLite3 en el navegador web con JavaScript desde WASM, pero si tú quieres algo más simple o normal entonces puedes leer:

Usar Wasm es agregarle dificultad al desarrollo, pero puede que te interese por su rendimiento o para que el código no se pueda modificar tan fácil; justamente es por eso que hago este tutorial.

Sobre WebAssembly

Ya he hablado sobre WebAssembly anteriormente, y ya te he mostrado un ejemplo con Golang que te recomiendo leer. El ejemplo de hoy, de hecho, también usa Golang.

Antes de continuar, tengo que hacer un paréntesis y explicar un poco.

En Go, podemos tener SQLite3 nativamente (o casi) gracias a que se puede compilar la librería original de C. Si lo compilamos para una plataforma de escritorio como Windows no habrá problema porque ahí existen sistemas de archivos para persistir la BD.

Luego, con OPFS en los navegadores, podemos tener un sistema de archivos; así es como funciona SQLite3.

Lo malo de esto es que todavía no he encontrado la manera de compilar SQLite3 en Go y hacer que use OPFS nativamente. Es decir, compilarla y decirle algo como “Oye, usa el OPFS para guardar la BD SQLite3”

Entonces por ahora solo vamos a invocar a las APIs de JavaScript desde WASM, y esperemos que más adelante alguien muestre un ejemplo de cómo hacerlo nativamente.

Iniciando BD SQLite3 desde WebAssembly

Con JavaScript tenemos el siguiente código que inicia el módulo de SQLite3:

	const sqlite3 = await sqlite3InitModule({
		print: console.log,
		printErr: console.error,
	});
	if ('opfs' in sqlite3) {
		db = new sqlite3.oo1.OpfsDb(NOMBRE_BASE_DE_DATOS);
		console.log('OPFS is available, created persisted database at', db.filename);
	} else {
		db = new sqlite3.oo1.DB(NOMBRE_BASE_DE_DATOS, 'ct');
		console.log('OPFS is not available, created transient database', db.filename);
	}

Entonces si queremos hacer lo mismo con Go y WebAssembly tenemos que hacer esto. Primero definimos una envoltura de la base de datos que tendrá un js.Value:

type EnvolturaDeBaseDeDatos struct {
	BaseDeDatos js.Value
}

Después iniciamos la BD. Es el mismo código de JavaScript, pero ahora “traducido” a Go. Transformado se ve así:

func iniciarBaseDeDatos() {
	consola := js.Global().Get("console").Get("log")
	argumentosParaExec := js.Global().Get("Object").New()
	argumentosParaExec.Set("print", consola)
	argumentosParaExec.Set("printErr", consola)
	js.
		Global().
		Get("sqlite3InitModule").
		Invoke(argumentosParaExec).
		Call("then", js.FuncOf(func(this js.Value, parametros []js.Value) interface{} {
			sqlite3 := parametros[0]
			propiedadOpfs := sqlite3.Get("opfs")
			if propiedadOpfs.IsUndefined() {
				log.Printf("No existe opfs")
				return nil
			}
			envolturaBd = EnvolturaDeBaseDeDatos{
				BaseDeDatos: sqlite3.Get("oo1").Get("OpfsDb").New(NombreBaseDeDatos),
			}
			CrearTablas()
			envolturaBd.PostMessage("iniciado", []interface{}{})
			return nil
		}))
}

Después de eso ya podemos invocar a envolturaBd.BaseDeDatos, por ejemplo, para invocar a exec:

func (envolturaBd *EnvolturaDeBaseDeDatos) exec(sql string, bind []interface{}, returnValue string, rowMode string) js.Value {
	argumentosParaExec := js.Global().Get("Object").New()
	argumentosParaExec.Set("sql", sql)
	argumentosParaExec.Set("bind", bind)
	argumentosParaExec.Set("returnValue", returnValue)
	argumentosParaExec.Set("rowMode", rowMode)
	return envolturaBd.BaseDeDatos.Call("exec", argumentosParaExec)
}

Luego compilamos el main.go al WebAssembly. Podemos exportar funciones así:

js.Global().Set("insertarPersona", js.FuncOf(InsertarPersona))

Importar WebAssembly en Worker

Ahora en nuestro worker vamos a importar el wasm generado previamente. En mi caso uso Vite, así que dejo mi main.wasm en la carpeta src y finalmente mi worker queda así, en donde simplemente invoca a las funciones expuestas de WebAssembly:

import "./wasm_exec.js"
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
import MODULO_WASM from "./main.wasm"

self.iniciar = async () => {
	const go = new Go();
	const result = await WebAssembly.instantiateStreaming(fetch(MODULO_WASM), go.importObject)
	go.run(result.instance);
}
self.onmessage = async (evento) => {
	let datos = evento.data.datos;
	const accion = evento.data.accion;
	if (!datos || datos.length <= 0) {
		datos = [];
	}
	if (self[accion]) {
		await self[accion](...datos);
	} else {
		console.error("invocando a función que no existe: %s", accion)
	}
}

Lo bueno de esto es que todas las funciones las contendrá el archivo wasm, pero al final estamos interactuando con la BD de SQLite3 directamente y alojada en el navegador web.

Ahora solo hay que importar el worker donde sea que lo vayamos a usar, e invocar a “iniciar”. Por ejemplo:

const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
worker.postMessage({ datos: [], accion: "iniciar" });

Esto abre muchas posibilidades, porque podríamos distribuir nuestro wasm como un servicio o una api para que los demás puedan usarlo, y solo tendrían que conectar el Worker a sus aplicaciones web.

Espero traer más ejemplos usando estas tecnologías.

Encantado de ayudarte


Estoy disponible para trabajar en tu proyecto, modificar el programa del post o realizar tu tarea pendiente, no dudes en ponerte en contacto conmigo.

No te pierdas ninguno de mis posts

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.

Dejar un comentario