Saltar al contenido

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.db es el Db del host: pluginConn(ctx.db) le da las tablas del plugin.
  • Para avisar al equipo, notify con permission (sale solo a quien tiene el permiso) y realtime.publish para 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 y ctx.db es 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.saved es un callHook que 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 test

El 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).

Siguiente: construir, firmar e instalar →

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