Apariencia
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 enplugin_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
pinnedtieneDEFAULT 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 pinnedVersió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 testSi 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.