Saltar al contenido

3. La migración SQL ​

Queremos poder fijar notas. Eso necesita una columna nueva. La migración 001_notas.sql ya salió con la 1.0.0, así que no se edita: añadimos 002.

Reglas de las migraciones ​

  • Ficheros migrations/*.sql, en orden alfabético. Cada uno corre en su propia transacción y se anota en plugin_migrations; solo corren las que no constan.
  • Idempotentes: CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, INSERT … WHERE NOT EXISTS. Un reintento no puede romper nada.
  • Compatibles hacia atrás: «Versión anterior» en el panel vuelve al código anterior, pero no deshace migraciones. La versión 1.0.0 tiene que seguir funcionando con la columna nueva (por eso pinned tiene DEFAULT false).
  • Las sentencias se separan por ; a final de línea. No pongas un ; a final de línea dentro de una función o de un literal. La última sentencia puede ir sin ;.
  • Las tablas propias llevan el prefijo del plugin (notas_items). No alteres tablas de otro módulo: si necesitas una columna del núcleo, es un cambio del host.
sql
-- Notas 1.1: notas fijadas. Idempotente.
ALTER TABLE notas_items ADD COLUMN IF NOT EXISTS pinned boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS notas_items_pinned_idx ON notas_items (pinned) WHERE pinned

Versión, tipos y ruta ​

Sube la versión en plugin.json: el instalador omite una versión ya instalada, así que sin subirla la 1.1.0 no se instalaría encima de la 1.0.0.

diff
-  "version": "1.0.0",
+  "version": "1.1.0",
@@
-  "description": "Lista de elementos del local con alta y borrado.",
+  "description": "Tablón de notas del local con notas fijadas y aviso diario.",

El host no conoce tu columna: añádela al tipo de la tabla. De paso, tables.ts recibe el permiso notas.pin (se declara en el paso 6) y el cast inverso pluginConn, que usaremos en el paso 7:

ts
import type { Kysely, Transaction } from "kysely";
import type { Conn } from "@host/db";
import type { DB } from "@host/db/types";
import type { Permission } from "@host/lib/permissions";
import type { CreatedAt, Generated, Id, UpdatedAt } from "@host/db/schema/common";

/** Tablas propias del plugin. En TypeScript, camelCase; en SQL, snake_case (el CamelCasePlugin del host convierte). */
export interface NotasItemsTable {
  id: Id;
  name: string;
  note: string | null;
  /** Añadida por la migración 002. `Generated`: opcional al insertar (la base pone `false`). */
  pinned: Generated<boolean>;
  createdAt: CreatedAt;
  updatedAt: UpdatedAt;
}
export interface NotasTables {
  notasItems: NotasItemsTable;
}

/** Base de datos tal como la ve el plugin: la del host más las tablas propias (el host entrega el mismo `Kysely`). */
export type PluginDB = DB & NotasTables;
export type HDb = Kysely<PluginDB>;
export type HConn = Kysely<PluginDB> | Transaction<PluginDB>;

/** Los permisos del plugin no están en el tipo `Permission` del host (se registran al cargar): este cast es el único punto. */
export const perm = (p: "notas.view" | "notas.edit" | "notas.pin"): Permission => p as Permission;

/** Las funciones del host (`audit`, `notify`…) piden su `Conn`, sin las tablas del plugin: este cast es el único punto. */
export const hostConn = (c: HConn): Conn => c as unknown as Conn;

/** Lo contrario: una conexión del host (la de un evento, un gancho o una tarea) vista con las tablas del plugin. */
export const pluginConn = (c: Conn): HConn => c as unknown as HConn;

Un esquema nuevo para la petición de fijar:

diff
 export type CreateItemT = z.infer<typeof CreateItem>;
+
+export const PinItem = z.object({ pinned: z.boolean() });

Y las rutas: el resumen ahora cuenta también las fijadas, la lista pone las fijadas primero y hay una ruta para fijar o soltar. Fijar es una acción distinta de editar y tendrá su permiso propio:

ts
import type { FastifyInstance } from "fastify";
import { requirePermission } from "@host/http/auth";
import { notFound } from "@host/http/errors";
import { parse } from "@host/http/reply";
import { audit } from "@host/lib/audit";
import { newId } from "@host/lib/ids";
import { CreateItem, PinItem } from "./schemas";
import { hostConn, perm, type HDb } from "./tables";

/** Las rutas se montan bajo /api/v1: aquí se escribe `/notas/items`, no `/api/v1/notas/items`. */
export async function notasRoutes(app: FastifyInstance): Promise<void> {
  // El host entrega su Kysely tipado sin las tablas del plugin: este cast, una vez por fichero, las añade.
  const db = app.deps.db as unknown as HDb;
  const view = { preHandler: requirePermission(perm("notas.view")) };
  const edit = { preHandler: requirePermission(perm("notas.edit")) };
  const pin = { preHandler: requirePermission(perm("notas.pin")) };

  app.get("/notas/items", view, async () => ({
    items: await db.selectFrom("notasItems").selectAll().orderBy("pinned", "desc").orderBy("createdAt", "desc").execute(),
  }));

  // Resumen para la cabecera de la pantalla: dos cifras en una sola consulta
  app.get("/notas/summary", view, async () => {
    const row = await db
      .selectFrom("notasItems")
      .select(({ fn }) => [fn.countAll<string>().as("total"), fn.count<string>("id").filterWhere("pinned", "=", true).as("pinned")])
      .executeTakeFirstOrThrow();
    return { total: Number(row.total), pinned: Number(row.pinned) };
  });

  app.post("/notas/items", edit, async (req, reply) => {
    const body = parse(CreateItem, req.body);
    // Cambio + auditoría en la misma transacción
    const item = await db.transaction().execute(async (trx) => {
      const row = await trx
        .insertInto("notasItems")
        .values({ id: newId("itm"), name: body.name, note: body.note ?? null })
        .returningAll()
        .executeTakeFirstOrThrow();
      await audit(hostConn(trx), req, { action: "notas.item_created", entity: "notasItems", entityId: row.id, after: { name: row.name } });
      return row;
    });
    return reply.code(201).send(item);
  });

  // Fijar o soltar una nota. Es una acción distinta de editar, con su propio permiso.
  app.patch<{ Params: { id: string } }>("/notas/items/:id/pin", pin, async (req) => {
    const body = parse(PinItem, req.body);
    const row = await db.transaction().execute(async (trx) => {
      const r = await trx.updateTable("notasItems").set({ pinned: body.pinned, updatedAt: new Date() }).where("id", "=", req.params.id).returningAll().executeTakeFirst();
      if (r) await audit(hostConn(trx), req, { action: "notas.item_pinned", entity: "notasItems", entityId: r.id, after: { pinned: r.pinned } });
      return r;
    });
    if (!row) throw notFound("Elemento");
    return row;
  });

  app.delete<{ Params: { id: string } }>("/notas/items/:id", edit, async (req, reply) => {
    const removed = await db.transaction().execute(async (trx) => {
      const r = await trx.deleteFrom("notasItems").where("id", "=", req.params.id).executeTakeFirst();
      if (!r.numDeletedRows) return false;
      await audit(hostConn(trx), req, { action: "notas.item_deleted", entity: "notasItems", entityId: req.params.id });
      return true;
    });
    if (!removed) throw notFound("Elemento");
    return reply.code(204).send();
  });
}
bash
npm run typecheck
npm test

Si borran tus datos. El panel ofrece «Desinstalar y borrar datos»: elimina las tablas que tus migraciones crean con CREATE TABLE. No metas en ellas datos que no sean de tu plugin. Ver Ciclo de vida.

Siguiente: la página del admin →

Contrato con el host: apiVersion 2. Sitio estático: no recoge datos ni usa servicios de terceros.