Apariencia
7. Tareas programadas y ganchos
Tres mecanismos para trabajar fuera de una petición HTTP: tareas periódicas (schedules), eventos de dominio (domainHandlers) y ganchos (hooks). Los tres se declaran en el ZModule. Hay un cuarto, los jobs de la cola, para lo que sale fuera (Jobs y schedules).
Tarea programada
El worker ejecuta las tareas de schedules con every: "minute" | "hour" | "day"; la diaria corre a at (hora local, por defecto 07:00). Reglas:
- Idempotente. Si el worker se reinicia, puede ejecutarse otra vez: guarda tu propio estado (aquí, el último día avisado con
getSetting/setSetting). - Con el módulo apagado se salta sola, salvo que marques
runWhenDisabled: true(limpieza de datos, RGPD). - El contexto es
{ db, config, realtime, log }.ctx.dbes elDbdel host:pluginConn(ctx.db)le da las tablas del plugin. - Para avisar al equipo,
notifyconpermission(sale solo a quien tiene el permiso) yrealtime.publishpara refrescar los paneles abiertos. Los textos van como funciones del idioma para guardarse en los cuatro idiomas.
ts
import { trFor } from "@host/lib/i18n";
import { notify } from "@host/lib/notify";
import { getSetting, setSetting } from "@host/lib/settings";
import { currentBusinessDate } from "@host/lib/time";
import type { Schedule } from "@host/modules/types";
import { perm, pluginConn } from "./tables";
/**
* Recordatorio diario: avisa de las notas fijadas que siguen en el tablón. Idempotente: guarda el último día avisado, de
* modo que un reinicio del worker no repite el aviso. Con el módulo apagado el worker la salta solo.
*/
export const notasSchedules: Schedule[] = [
{
name: "notas.recordatorio",
every: "day",
at: "08:00",
run: async (ctx) => {
const db = pluginConn(ctx.db);
const today = currentBusinessDate();
const state = await getSetting(ctx.db, "notas_state", { lastReminder: "" });
if (state.lastReminder === today) return;
const row = await db.selectFrom("notasItems").select(({ fn }) => fn.countAll<string>().as("n")).where("pinned", "=", true).executeTakeFirstOrThrow();
const n = Number(row.n);
await setSetting(ctx.db, "notas_state", { lastReminder: today });
if (!n) return;
await notify(ctx.db, {
type: "system",
title: (l) => trFor(l, "Notas fijadas en el tablón"),
body: (l) => trFor(l, "Hay {n} notas fijadas.", { n }),
link: "/notas",
permission: perm("notas.view"),
});
ctx.realtime.publish("notifications", "notification.created", {});
},
},
];Evento de dominio y gancho
El núcleo no importa plugins. Se comunica con ellos de dos formas (detalle y tablas):
- Eventos (
domainHandlers): el núcleo los emite dentro de su transacción yctx.dbes esa transacción. Si tu manejador lanza, se deshace la operación del núcleo: sé mínimo y tolerante. - Ganchos (
hooks): puntos de extensión con valor de vuelta (callHook,firstHook,filterHook). Sin nadie enganchado, el núcleo sigue su camino por defecto.integration.savedes uncallHookque se lanza en segundo plano al guardar una integración; un plugin solo puede observarlo.
Los manejadores se registran aunque el módulo esté apagado. Por eso cada uno empieza por isModuleEnabled(ctx.db, "notas") y no hace nada si no lo está.
ts
import { newId } from "@host/lib/ids";
import { isModuleEnabled } from "@host/lib/modules";
import type { DomainHandlers, HookHandlers } from "@host/lib/domainEvents";
import { pluginConn } from "./tables";
/**
* Eventos de dominio: el núcleo los emite dentro de su transacción y `ctx.db` ES esa transacción. Si este manejador
* lanza, se deshace la operación del núcleo (aquí, el movimiento de stock): por eso es mínimo y no hace nada con el
* módulo apagado.
*/
export const notasEvents: DomainHandlers = {
"stock.low": async (ctx, payload) => {
if (!(await isModuleEnabled(ctx.db, "notas"))) return;
await pluginConn(ctx.db)
.insertInto("notasItems")
.values({ id: newId("itm"), name: "Stock bajo", note: `Artículo ${String(payload.itemId)}: quedan ${String(payload.stock)}.`, pinned: true })
.execute();
},
};
/**
* Ganchos: el núcleo llama y quien esté enganchado responde. `integration.saved` es un `callHook`: se lanza en segundo
* plano al guardar una integración y los resultados se descartan, así que un plugin solo puede observar.
*/
export const notasHooks: HookHandlers = {
"integration.saved": async (ctx, payload) => {
if (!(await isModuleEnabled(ctx.db, "notas"))) return undefined;
await pluginConn(ctx.db)
.insertInto("notasItems")
.values({ id: newId("itm"), name: "Integración guardada", note: `Proveedor: ${String(payload.provider)}.` })
.execute();
return undefined;
},
};Se declaran en el módulo:
diff
-import { notasRoutes } from "./routes";
+import { notasEvents, notasHooks } from "./reactions";
+import { notasRoutes } from "./routes";
+import { notasSchedules } from "./schedules";
@@
- routes: notasRoutes,
+ routes: notasRoutes,
+ schedules: notasSchedules,
+ domainHandlers: notasEvents,
+ hooks: notasHooks,Probar
El host de pruebas del kit solo trae los módulos que usa la plantilla. Cuando tu plugin importa más (lib/modules, lib/notify, lib/settings, lib/time, trFor…), se añaden como dobles. Un único fichero los centraliza y cada test sustituye los suyos:
js
import { createHostMock } from "@hury/plugin-kit/testing";
/**
* El host de pruebas del kit solo trae lo que usa la plantilla. Un plugin que importa más módulos del host
* (aquí modules, notify, settings, time e i18n/trFor) los añade como dobles; cada test puede sustituir los suyos.
*/
export function hostFor(root, overrides = {}) {
const base = createHostMock(root);
return createHostMock(root, {
"lib/i18n": { ...base["lib/i18n"], trFor: (_locale, text, params = {}) => text.replace(/\{(\w+)\}/g, (_, k) => String(params[k])) },
"lib/modules": { isModuleEnabled: async () => true },
"lib/notify": { notify: async () => 0 },
"lib/settings": { getSetting: async (_db, _key, defaults) => defaults, setSetting: async () => {} },
"lib/time": { currentBusinessDate: () => "2026-03-01" },
...overrides,
});
}La plantilla usa hostFor en lugar del host por defecto:
diff
-import { createFakeDb, createHostMock, createTestApp, loadPluginApi, projectRequire } from "@hury/plugin-kit/testing";
+import { createFakeDb, createTestApp, loadPluginApi, projectRequire } from "@hury/plugin-kit/testing";
+import { hostFor } from "./host.mjs";
@@
- ({ module: mod } = await loadPluginApi(root));
+ ({ module: mod } = await loadPluginApi(root, hostFor(root)));
@@
- host = createHostMock(root);
+ host = hostFor(root);Y un fichero de pruebas propio para lo que has añadido: el resumen, el permiso de fijar, que el recordatorio avise una sola vez al día, y que el evento y el gancho no hagan nada con el módulo apagado:
js
import assert from "node:assert/strict";
import { before, describe, test } from "node:test";
import { createFakeDb, createTestApp, loadPluginApi, projectRequire } from "@hury/plugin-kit/testing";
import { hostFor } from "./host.mjs";
const root = new URL("..", import.meta.url).pathname;
const AS = (...perms) => ({ "x-test-permissions": perms.join(",") });
describe("resumen y fijar", () => {
let app, queries, answer;
before(async () => {
const host = hostFor(root);
const { module } = await loadPluginApi(root, host);
const fake = createFakeDb(projectRequire(root)("kysely"), () => answer);
queries = fake.queries;
app = await createTestApp({ module, db: fake.db, host });
});
test("GET /notas/summary devuelve las dos cifras en una consulta", async () => {
answer = [{ total: "5", pinned: "2" }];
queries.length = 0;
const res = await app.inject({ method: "GET", url: "/notas/summary", headers: AS("notas.view") });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json(), { total: 5, pinned: 2 });
assert.equal(queries.length, 1);
});
test("fijar exige notas.pin: notas.edit no basta", async () => {
answer = [{ id: "itm_1", name: "Uno", note: null, pinned: true }];
const body = { pinned: true };
const denied = await app.inject({ method: "PATCH", url: "/notas/items/itm_1/pin", headers: AS("notas.edit"), payload: body });
assert.equal(denied.statusCode, 403);
const ok = await app.inject({ method: "PATCH", url: "/notas/items/itm_1/pin", headers: AS("notas.pin"), payload: body });
assert.equal(ok.statusCode, 200);
assert.equal(ok.json().pinned, true);
});
});
describe("tarea diaria, evento y gancho", () => {
test("el recordatorio avisa una sola vez al día", async () => {
const sent = [];
let state = { lastReminder: "" };
const host = hostFor(root, {
"lib/notify": { notify: async (_db, n) => void sent.push(n) },
"lib/settings": { getSetting: async () => state, setSetting: async (_db, _key, value) => void (state = value) },
});
const { module } = await loadPluginApi(root, host);
const { db } = createFakeDb(projectRequire(root)("kysely"), () => [{ n: "2" }]);
const ctx = { db, config: {}, log() {}, realtime: { publish() {} } };
const task = module.schedules.find((s) => s.name === "notas.recordatorio");
await task.run(ctx);
await task.run(ctx);
assert.equal(sent.length, 1);
assert.equal(sent[0].permission, "notas.view");
});
test("stock.low crea una nota fijada; con el módulo apagado no hace nada", async () => {
const on = await loadPluginApi(root, hostFor(root));
const a = createFakeDb(projectRequire(root)("kysely"), () => []);
await on.module.domainHandlers["stock.low"]({ db: a.db, req: null }, { itemId: "art_1", stock: 1 });
assert.match(a.queries.at(-1).sql, /insert into "notas_items"/);
const off = await loadPluginApi(root, hostFor(root, { "lib/modules": { isModuleEnabled: async () => false } }));
const b = createFakeDb(projectRequire(root)("kysely"), () => []);
await off.module.domainHandlers["stock.low"]({ db: b.db, req: null }, { itemId: "art_1", stock: 1 });
assert.equal(b.queries.length, 0);
});
test("integration.saved añade una nota con el proveedor", async () => {
const { module } = await loadPluginApi(root, hostFor(root));
const f = createFakeDb(projectRequire(root)("kysely"), () => []);
const result = await module.hooks["integration.saved"]({ db: f.db, req: null }, { provider: "instagram" });
assert.equal(result, undefined);
assert.ok(f.queries.at(-1).parameters.includes("Proveedor: instagram."));
});
});bash
npm run typecheck
npm testEl doble de la base de datos (createFakeDb) registra el SQL y devuelve las filas que le indiques: cubre rutas, validación, permisos y las consultas emitidas. No ejecuta tus migraciones ni PostgreSQL real; eso se comprueba instalando el plugin en una instalación de pruebas (paso 8).