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.