Apariencia
2. La API: una ruta
La plantilla ya expone GET/POST /notas/items y DELETE /notas/items/:id. Añadimos una ruta de resumen para la cabecera de la pantalla.
Las reglas que sigue api/routes.ts:
- Las rutas se montan bajo
/api/v1: se escribe/notas/summary, no/api/v1/notas/summary. Todas empiezan por el nombre del plugin, para no chocar con las del núcleo ni las de otros plugins. - Permisos en cada ruta con
requirePermission(perm("notas.view")). Elperm()es el cast detables.ts: el tipoPermissiondel host no conoce los permisos del plugin porque se registran al cargar. - Datos validados con zod y
parse(Schema, req.body)de@host/http/reply, que responde 400VALIDATION_ERRORcon el detalle. - Cambio y auditoría en la misma transacción (
auditde@host/lib/audit). - El gate del módulo lo pone el marco. Con el módulo apagado, todas las rutas del
ZModuleresponden 404MODULE_DISABLEDsin que tengas que escribir nada. app.deps.dbes elKyselydel host, tipado sin las tablas del plugin. Se castea una vez por fichero aHDb, que sí las conoce.
Sustituye api/routes.ts por este (la plantilla más la ruta GET /notas/summary):
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 } 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")) };
app.get("/notas/items", view, async () => ({
items: await db.selectFrom("notasItems").selectAll().orderBy("createdAt", "desc").execute(),
}));
// Resumen para la cabecera de la pantalla
app.get("/notas/summary", view, async () => {
const row = await db.selectFrom("notasItems").select(({ fn }) => fn.countAll<string>().as("total")).executeTakeFirstOrThrow();
return { total: Number(row.total) };
});
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);
});
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();
});
}fn.countAll<string>() es string porque PostgreSQL devuelve bigint como texto; se convierte con Number.
Comprueba tipos y pruebas:
bash
npm run typecheck
npm testQué ocurre al compilar.
import { requirePermission } from "@host/http/auth"no incluye el código del host en tu paquete:hury-plugin buildlo convierte enconst __m = globalThis.__HURY_API__["http/auth"]; export const requirePermission = __m.requirePermission. Por eso solo puedes importar lo que enumeran los módulos del host.