API con Next.js y Kysely desarrollada y hospedada en adJ/OpenBSD 7.7p1
1. Introducción
Para una API REST simple que responde JSON nos ha resultado posible
remplazar las capas superiores de la pila
adJ + PostgreSQL + nginx + Ruby + Rails + Javascript + Stimulus + Bootstrap
por
adJ + PostgreSQL + nginx + node.js + pnpm + Typescript + Kysely + Next.js + React + TailwindCSS
En la sección 2 explicamos lo que nos ha motivado a experimentar y parte
del proceso. Si sólo desea poner a operar un ejemplo puede omitir esa sección.
2. Rationale
Para desarrollar y alojar aplicaciones sobre el sistema operativo
adJ/OpenBSD, usando el motor de base de datos PostgreSQL y el servidor web
nginx, hemos empleado ampliamente
Ruby on Rails + Javascript + Stimulus.
Por problemas de velocidad con Rails (en particular con Rails 8) y por la
creciente popularidad de Javascript hemos experimentado con éxito con
vite y Vue
(ver https://dhobsd.pasosdejesus.org/visualizador-de-markdown-con-vite-y-vue-en-openbsd-adj.html y la rama
vue de https://github.com/pasosdeJesus/learn.tg/tree/vue),
pero por la amplia (y en ocasiones exclusiva) disponibilidad de herramientas
para cripto-activos con Typescript y React, hemos estado buscando otra pila
que incluya Typescript y React pero que a su vez opere sobre
adJ/OpenBSD+PostgreSQL+nginx.
2.1 Convención de idioma en fuentes
En las convenciones de msip y los motores y aplicaciones que desarrollamos
con este, dimos prioridad al uso del español en las fuentes, para favorecer y
esperando contribuciones de desarrolladores hispanohablantes.
(ver https://gitlab.com/pasosdeJesus/msip/-/blob/main/doc/convenciones.md ).
Sin embargo en general, no se han presentado contribuciones voluntarias.
Con la nueva pila que estamos proponiendo, para favorecer a desarrolladores de
Sierra Leona, adoptaremos la convención tradicional de maximizar el inglés en
las fuentes (pidiendo disculpa anticipadas y correcciones por los posibles
errores ortográficos o gramaticales que el autor cometerá con seguramente mayor
frecuencia al emplear una idioma no nativo).
2.2 Typescript
Somos consientes de que el monopólico Microsoft lidera el desarrollo de
Typescript y seguramente emplea su estrategia de Adoptar, Extender y
Exterminar contra Javascript. Para mitigarla empleamos configuraciones en
tsconfig.json que mantienen compatibilidad con Javascript y
por el momento nos tranquiliza que Typescript sigue dependiendo de
Javascript y que en el pasado ECMAscript adoptó características de
Typescript –como la sintaxis de clases– y esperamos que vuelva a hacerlo.
También reconocemos que Typescript facilita la inter-operación entre
paquetes escritos con diversas versiones de javascript o diverso uso
de modulos (CommonJS, ESM, etc), su tipado fuerte evita algunas errores
y su transpilador generar mensajes de error mucho más entendibles que
javascript plano. También reconocemos que en ocasiones el tipado
exigido parece no tener sentido y resultar como interminable.
2.2 Next.js
Next.js es desarrollado por Vercel y entre los frameworks con
React se ha caracterizado por permitir la generación de contenido al
lado del servidor.
En experimentos pasados con Next.js encontramos que no operaba sobre
OpenBSD/adJ pero esto cambió desde finales de 2023 con Next.js 13.3 que
emplea WASM cuando no hay binarios de SWC (su transpilador en Rust) para la
plataforma en la que corre.
2.3 React
React es desarrollado por Meta/Facebook y se ha caracterizado como una
librería para interfaces basada en componentes. En experimentos anteriores
nos habíamos chocado con dificultades que hacían muy complejo desarrollar,
mantener y depurar proyectos con React. Desde la versión 16.8 react
simplificó conceptos y el desarrollo mediante los componentes
funcionales y ganchos como useEffect para manejar el estado.
Además Next.js desde su versión 13.4 provee enrutamiento de manera muy
natural para proyectos con React.
2.4 pnpm
Notamos que para mejorar velocidad se ha vuelto un patrón recurrente en
proyectos Javascript/Typescript proveer binarios precompilados para las
plataformas más populares (MacOS, Linux y Windows) y no ofrecer WASM
ni opción de pre-compilar siquiera para FreeBSD y menos para OpenBSD/adJ
y demás plataformas.
Las versiones recientes del manejador de paquete pnpm están (1) ayudando
al intentar compilar diversos paquetes sin precompilados para OpenBSD/adJ
generando binarios para la plataforma en la que corre de manera automática,
(2) facilitando especificar usar versión WASM de algunos paquetes que no
tienen precompilados para OpenBSD/adJ o que no se han portado explícitamente.
2.5 TailwindCSS
Las versiones más recientes de TailwindCSS requieren binarios precompilados,
sin soportar OpenBSD/adJ, pero sus desarrolladores han sido receptivos a
nuestras solicitudes (ver
https://github.com/tailwindlabs/tailwindcss/issues/17452) y han empezado a
proveerlos en WASM y a recomendar el uso de pnpm.
2.6 Kysely
Respecto a un ORM para abstraer la base de datos y en particular PostgreSQL,
consideramos que Javascript/Typescript aún no tiene un proyecto fuerte
y completo que opere sobre adJ/OpenBSD.
En https://github.com/pasosdeJesus/stable-sl/wiki/Looking-for-an-ORM-that-works-good-with-typescript-and-that-runs-on-adJ-OpenBSD-7.7/ evaluamos
Prism, Sequelize, TypeORM, drizzle y Kysely, para concluir que Kysely
es la mejor opción al momento de este escrito porque:
* Opera sobre adJ/OpenBSD 7.7 (a diferencia de Prism)
* Opera con Typescript (a diferencia de TypesORM y sequelize)
* Cuenta con migraciones hacia arriba y hacia abajo (a diferencia de Drizzle)
* Permite darle prioridad a la base de datos como fuente de "verdad"
respecto a la estructura de la base de datos, a diferencia
de los demás que pudimos correr en adJ que le dan esa prioridad a las
fuentes en Typescript/Javascript.
Aunque Kysely no se define como un ORM, hemos encontrado que junto con
kysely-ctl y kysely-codegen da suficiente funcionalidad para:
* Manejar migraciones hacía arriba y hacia abajo desde la línea de ordenes.
* Generar interfaces de la base de datos en TypeScript a partir de
la base PostgreSQL.
Kysely aún no soporta tipos para funciones agregadas ni muchas otras
posibilidades de SQL y PostgreSQL, pero cuenta con un método execute
que permite ejecutar SQL puro.
Quisieramos mantener en SQL estas convenciones:
* Tablas no relacionadas con convención flatcase de una o
excepcionalmente 2 palabras
* Relaciones entre tablas separadas por _ en orden alfabético de tablas
* Campos de tablas con convención snake_case
Y en javascript la típica convención camleCase.
La actual documentación de Kysely nos ha parecido deficiente, pues por ejemplo
no indica como iniciar un proyecto. Esperamos que este artículo ayude a
suplir.
3. Inicio del proyecto
Revisemos versiones de herramientas:
% uname -a
OpenBSD selah.pasosdeJesus.org 7.7 APRENDIENDODEJESUS.MP#2 amd64
% node -v
v22.20.0
% npm -v
11.6.2
% pnpm -v
10.20.0
Supongamos que lo llamaremos purchase_api, comienza con Next.js (16) pero sin
turbopack que presenta problemas con wasm:
% npx create-next-app purchase_api
✔ Would you like to use the recommended Next.js defaults? › No, customize
settings
✔ Would you like to use TypeScript? … Yes
✔ Which linter would you like to use? › ESLint
✔ Would you like to use React Compiler? … No
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like your code inside a `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to use Turbopack? (recommended) … No
✔ Would you like to customize the import alias (`@/*` by default)? … No
...
% cd purchase_api
% rm -rf package-lock.json node_modules
Asegura tener pnpm reciente:
% doas npm i -g pnpm
y edita package.json para mejorarlo y para agregar al final:
"pnpm": {
"overrides": {
"lightningcss": "npm:lightningcss-wasm",
"@tailwindcss/oxide": "npm:@tailwindcss/oxide-wasm32-wasi"
}
},
"type": "module"
También recomendamos que agregues en la sección script:
"typecheck": "tsc --noEmit"
para que puedas revisar la sintaxis de todo el proyecto con pnpm typecheck.
Para que tu proyecto sea más compatible con Javascript edita tsconfig.json y
en compilerOptions agrega:
"allowImportingTsExtensions": true,
"allowSyntheticDefaultImports": false,
"baseUrl": ".",
"forceConsistentCasingInFileNames": true,
"strictNullChecks": true,
Completa la instalación de paquetes y aprueba compilar para todos los paquetes
que lo requieren:
% pnpm install # repitelo hasta que no haya errores
% pnpm approve-builds
✔ Choose which packages to build (Press <space> to select, <a> to toggle all,
<i> to invert selection) · @tailwindcss/oxide, sharp, unrs-resolver
✔ The next packages will now be built: @tailwindcss/oxide, sharp,
Do you approve? (y/N) · true
Comprueba que puedes ver la página de prueba en modo desarrollo ejecutando
% pnpm dev
y examina con un navegador en http://127.0.0.1:3000
Continúa agregando tsx y Kysely:
% pnpm add tsx
% pnpm add dotenv kysely kysely-postgres-js pg
% pnpm add -D @types/pg kysely-codegen kysely-ctl
% pnpm i
Crea un usuario para PostgreSQL (e.g kysely)
% doas su - _postgresql
% createuser kysely -s -h /var/www/var/run/postgresql/ -U postgres
% psql -h /var/www/var/run/postgresql/ -U postgres
> alter user kysely with password 'MiClave';
> \q
% exit
Y para simplificar la interacción con la base de datos configura el
nuevo usuario y su clave en tu ~/.pgpass con:
% echo "*:*:*:kysely:MiClave" >> ~/.pgpass
Y crea la base de datos, por ejemplo purchaseapi:
% createdb -U kysely -h /var/www/var/run/postgresql purchaseapi
Crea el archivo .env.template con un ejemplo de la configuración para
la base de datos:
PGHOST=/var/www/var/run/postgresql/
PGDATABASE=purchaseapi
PGUSER=kysely
PGPASSWORD=MyPass
DATABASE_URL=postgres://$PGDATABASE?user=$PGUSER&password=$PGPASSWORD&host=$PGHOST
Copialo en .env –asegura de que .env esté en su archivo .gitignore
para no ir a subirlo al repositorio git con claves– y editalo para
poner la información de la base de datos:
% cp .env.template .env
% vi .env
Para facilitar la interacción con la base de datos crea el script bin/psql
y pruébalo:
% mkdir bin
% cat <<EOF > bin/psql
#!/bin/sh
. ./.env
psql -h \$PGHOST -U \$PGUSER \$PGDATABASE
EOF
% chmod +x bin/psql
% bin/psql
Crea el archivo de configuración para kysely-ctl que también podrás
usar en tu proyecto en .config/kysely.config.ts con este contenido:
import { CamelCasePlugin, PostgresDialect } from 'kysely'
import { defineConfig, getKnexTimestampPrefix } from 'kysely-ctl'
import 'dotenv/config'
import { Pool } from 'pg';
export default defineConfig({
dialect: new PostgresDialect({
pool: new Pool({
host: process.env.PGHOST,
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
port: 5432,
}),
}),
migrations: {
migrationFolder: "../db/migrations",
getMigrationPrefix: getKnexTimestampPrefix,
},
seeds: {
seedFolder: "../db/seeds",
},
plugins: [new CamelCasePlugin()],
})
4. Crea tablas y datos semilla
Genera un plantilla para tu primera migración:
% ./node_modules/.bin/kysely migrate:make create_purchasequote
Y edita el archivo generado en el directorio migraciones (por ejemplo
migrations/20250609165549_create_purchasequote.ts) para crear
la primera tabla en el método up y
eliminar la tabla en el método down:
import type { Kysely } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
// Más información en https://kysely.dev/docs/migrations
await db.schema
.createTable('user')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('login', 'varchar(15)', (col) => col.notNull())
.addColumn('name', 'varchar(50)', (col) => col.notNull())
.addColumn('lastname', 'varchar(50)', (col) => col.notNull())
.addColumn('language', 'varchar(2)', (col) => col.notNull())
.addColumn('role', 'integer', (col) => col.notNull())
.addColumn('token', 'varchar(80)', (col) => col.notNull())
.addColumn('createdAt', 'bigint', (col) => col.notNull())
.addColumn('updatedAt', 'bigint', (col) => col.notNull())
.execute();
await db.schema
.createTable('purchasequote')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('buyerPhone', 'varchar(15)', (col) => col.notNull())
.addColumn('buyerName', 'varchar(80)', (col) => col.notNull())
.addColumn('buyerWallet', 'varchar(50)', (col) => col.notNull())
.addColumn('usdPriceInSle', 'real', (col) => col.notNull())
.addColumn('maximum', 'real', (col) => col.notNull())
.addColumn('minimum', 'real', (col) => col.notNull())
.addColumn('token', 'varchar(32)', (col) => col.notNull())
.addColumn('createdAt', 'bigint', (col) => col.notNull())
.addColumn('updatedAt', 'bigint', (col) => col.notNull())
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('purchasequote').execute()
await db.schema.dropTable('user').execute()
}
Ejecuta esta migración con:
% node_modules/.bin/kysely migrate:up
◐ Starting migration up
✔ Migration complete
ℹ Ran 1 migration:
[✓] 20250609165549_crea_cotizacioncompra
Si encuentras un problema con tu migración y no la has ejecutado en el sitio de
producción podrías revertirla con node_modules/.bin/kysely migrate:down,
arreglarla y volver a ejecutar migrate:up. Si ya la ejecutase
en el sitio de producción una mejor solución es crear una nueva
migración que en su método up deshaga lo problemático y haga lo que esperabas.
Después puedes inspeccionar las tablas generadas en la base de datos
con bin/psql o:
$ psql -h /var/www/var/run/postgresql -U kysely purchaseapi
# \dt
List of relations
Schema | Name | Type | Owner
--------+-----------------------+----------+--------
public | kysely_migration | table | kysely
public | kysely_migration_lock | table | kysely
public | purchasequote | table | kysely
public | purchasequote_id_seq | sequence | kysely
public | user | table | kysely
public | user_id_seq | sequence | kysely
(8 rows)
# \d purchasequote
Table "public.purchasequote"
Column | Type | Collation | Nullable |
Default
---------------+-----------------------+-----------+----------+-------------------------------------------
id | integer | | not null |
nextval('purchasequote_id_seq'::regclass)
buyerPhone | character varying(15) | | not null |
buyerName | character varying(80) | | not null |
buyerWallet | character varying(50) | | not null |
usdPriceInSle | real | | not null |
maximum | real | | not null |
minimum | real | | not null |
token | character varying(32) | | not null |
createdAt | bigint | | not null |
updatedAt | bigint | | not null |
Indexes:
"purchasequote_pkey" PRIMARY KEY, btree (id)
# \d kysely_migration
Table "public.kysely_migration"
Column | Type | Collation | Nullable | Default
-----------+------------------------+-----------+----------+---------
name | character varying(255) | | not null |
timestamp | character varying(255) | | not null |
Indexes:
"kysely_migration_pkey" PRIMARY KEY, btree (name)
# \d kysely_migration_lock
Table "public.kysely_migration_lock"
Column | Type | Collation | Nullable | Default
-----------+------------------------+-----------+----------+---------
id | character varying(255) | | not null |
is_locked | integer | | not null | 0
Indexes:
"kysely_migration_lock_pkey" PRIMARY KEY, btree (id)
# select * from kysely_migration;
name | timestamp
--------------------------------------+--------------------------
20250609231142_create_purchasequote | 2025-06-09T23:20:29.779Z
(1 row)
Time: 0,672 ms
# select * from kysely_migration_lock;
id | is_locked
----------------+-----------
migration_lock | 0
(1 row)
Time: 0,508 ms
Cada vez que modifiques la base de datos, usa kysely-codegen para generar
interfaces actualizadas para cada tabla en el archivo ./db/db.d.ts con:
% mkdir db
% ./node_modules/.bin/kysely-codegen --out-file ./db/db.d.ts
Si tienes datos iniciales para algunas tablas puedes insertarlos
con archivos semilla. Genera un archivo semilla con:
node_modules/.bin/kysely seed:make basicdata
Y edita el archivo generado (por ejemplo seeds/1749379714750_basicdata.ts)
con:
import { Insertable, Kysely, PostgresDialect, sql } from 'kysely';
import type { DB, Purchasequote } from '../../db/db.d.ts';
export async function seed(db: Kysely<any>): Promise<void> {
let quote:Insertable<Purchasequote> = {
id: 1,
minimum: 200,
maximum: 20,
buyer_name: "I",
buyer_phone: "1",
buyer_wallet: "0xb",
created_at: "12",
updated_at: "12",
token: "u",
usd_price_in_sle: 22.3,
}
let iquote = await db
.insertInto('purchasequote')
.values(quote)
.returningAll()
.executeTakeFirstOrThrow()
console.log("After insert iquote=", iquote)
// Serial not autoincremented because we set id:1 Need to update
let rs=await sql<any>`SELECT setval('public.purchasequote_id_seq', MAX(id))
FROM public.purchasequote`.execute(db)
console.log("rs=", rs)
}
y ejecutala con
% ./node_modules/.bin/kysely seed:run
5. Realiza operaciones CRUD
Y ahora ejemplifiquemos el uso de operaciones básicas en index.ts:
import { Insertable, Kysely, PostgresDialect, sql, Updateable } from 'kysely';
import 'dotenv/config'
import defineConfig from './.config/kysely.config.ts'
import type { DB, Purchasequote } from './db/db.d.ts';
const db = new Kysely<DB>({
dialect: defineConfig.dialect
})
let nquote:Insertable<Purchasequote> = {
minimum: 200,
maximum: 20,
buyer_name: "E",
buyer_phone: "0",
buyer_wallet: "0xa",
created_at: "11",
updated_at: "11",
token: "t",
usd_price_in_sle: 23.3,
}
let iquote = await db
.insertInto('purchasequote')
.values(nquote)
.returningAll()
.executeTakeFirstOrThrow()
console.log("After insert iquote=", iquote)
let rows = await db.selectFrom('purchasequote').selectAll().execute();
console.log("Query of all rows=", rows)
let crows = await sql<any>`select count(*) from purchasequote`.execute(db)
console.log("Count of rows=", crows.rows[0]?.count)
let uquote:Updateable<Purchasequote> = {
minimum: 300,
maximum: 30,
usd_price_in_sle: 24,
}
let rupdate=await db.updateTable('purchasequote').set(uquote).where(
'id', '=', iquote.id
).execute()
console.log("After update rupdate=", rupdate)
rows = await db.selectFrom('purchasequote').selectAll().execute();
console.log("Query of all rows=", rows)
let rdelete = await db.deleteFrom('purchasequote').where('id', '=', iquote.id)
.returningAll()
.executeTakeFirst()
console.log("After delete rdelete=", rdelete)
crows = await sql<any>`select count(*) from purchasequote`.execute(db)
console.log("Count of rows=", crows.rows[0]?.count)
await db.destroy();
Y ejecútalo con
% ./node_modules/.bin/tsx index.ts
6. Un punto final (endpoint) para la API
Si queremos un punto final para la API api/amount_purchase_quotes que responda
con GET el total de cotizaciones de compra, bastaría crear el directorio
apropiado:
% mkdir -p app/api/amount_purchase_quotes
y el archivo app/api/amount_purchase_quotes/route.ts con
"use server"
import { Kysely, PostgresDialect, sql } from 'kysely';
import { NextRequest, NextResponse } from 'next/server'
import type { DB } from '@/db/db.d.ts';
import { Pool } from 'pg'
import defineConfig from '../../../.config/kysely.config.ts'
export async function GET(req: NextRequest) {
console.log("** amount_purchase_quotes GET req=", req)
try {
const db = new Kysely<DB>({
dialect: defineConfig.dialect
})
const db = new Kysely<DB>({
dialect: defineConfig.dialect
})
const crows = await sql<any>`select count(*) from purchasequote`.execute(db)
const amount = crows.rows[0]?.count
console.log("Count of rows=", amount)
return NextResponse.json(
{
amount: amount
},
{status: 200}
)
} catch (error) {
console.error("Excepción error=", error)
return NextResponse.json(
{error: error},
{status: 500}
)
}
}
Podrá iniciar fronted in backend en modo de desarrollo con:
pnpm dev
y consultar con un navegador http://localhost:3000/api/amount_purchase_quotes
donde debe ver la cuenta de cotizaciones de compra.
7. Conclusión
En el momento de este escrito posible desarrollar APIs así como aplicaciones
cliente/servidor completas sobre OpenBSD/adJ 7.7p1 usando una pila tecnológica
actualizada con:
PostgreSQL + nginx + node.js + pnpm + Typescript + Kysely + Next.js + React + TailwindCSS
No es trivial migrar aplicaciones rails existentes pero nos parece viable y
de hecho necesario si se desean integrar en el web3 pues
la otra ruta de alcanzar con rails al ecosistema web3 desarrollado para
typescript requiere un esfuerzo inicial mayor y por la diferencia en la
cantidad de desarrolladores de rails y de typescript, en caso de hacerlo una
vez, nos parece inviable mantenerlo actualizado.
Mantener la pila mencionada al día y operativa sobre OpenBSD/adJ será más
factible si más desarrolladores sobre OpenBSD/adJ usan esta pila y ayudan
a reportar y resolver problemas que puedan surgir.
En proyectos web3 sobre Ethereum y otros blockchains compatibles hemos encontrado
que es posible pero no directamente usar hardhat para compilar y desplegar
contratos inteligentes, que trataremos en otro artículo.