import type { CommandContext } from "@tailor-platform/erp-kit/core"; import type { MachineUserName, TailorAnyDBType } from "@tailor-platform/sdk"; import { createExecutor, recordCreatedTrigger } from "@tailor-platform/sdk"; import { logger } from "@tailor-platform/sdk/runtime"; import type { DB, Transaction } from "../generated/kysely-tailordb"; import { deliverPlanned } from "../internal/drainDeliveries"; import { planPersistedEvent, type PersistedEventRow } from "../internal/planEvent"; import type { DispatchAdapters } from "../lib/dispatchAdapters"; import { errorAttrs } from "../lib/logAttrs"; import { permissions } from "../lib/permissions.generated"; /** * Primary trigger for delivery: a CDC `recordCreatedTrigger` on NotificationEvent. * * Each newly inserted PENDING event fires this executor, which re-reads the row * `forUpdate`, guards on `status = PENDING` (at-least-once CDC safety), and runs * the plan engine via the shared `planPersistedEvent` helper inside the * transaction, stamping the event DISPATCHED / NO_DELIVERY. After the plan * commits, the outbox rows it produced are delivered OUTSIDE the transaction so * a slow or failing provider can neither hold the row locks nor roll the plan * back; a crash before delivery leaves the rows for the redrain cron. * * App injects `notificationEventType` (the registered db type the trigger binds * to), `getDB` (the shared Kysely namespace), and the composed `adapters` (the * same instances dispatchNotification uses). */ export interface DispatchNotificationEventsParams { /** The registered NotificationEvent db type the CDC trigger binds to. */ notificationEventType: TailorAnyDBType; /** Returns the app-side Kysely instance for the shared DB. */ getDB: () => DB; /** Composed channel adapters (same instances used by dispatchNotification). */ adapters: DispatchAdapters; /** Machine user identity used to invoke the executor. */ invoker?: MachineUserName; } export function dispatchNotificationEvents(params: DispatchNotificationEventsParams) { return createExecutor({ name: "dispatch-notification-events", description: "Dispatches a NotificationEvent the moment it is created (CDC), invoking dispatchNotification and stamping the event DISPATCHED/NO_DELIVERY", // oxlint-disable-next-line typescript/no-unsafe-type-assertion trigger: recordCreatedTrigger({ type: params.notificationEventType as never }), operation: { kind: "function", invoker: params.invoker, // The CDC payload carries the new row; only the id is needed since the // executor re-reads the row forUpdate to take the PENDING-guard lock. body: async (args: { newRecord: { id: string } }) => { const eventId = args.newRecord.id; const db = params.getDB(); const now = new Date(); const ctx: CommandContext = { actorId: "notification-dispatch-runner", permissions: [permissions.ingest.logNotificationEvent], }; try { const outcome = await db.transaction().execute(async (trx: Transaction) => { const event = (await trx .selectFrom("NotificationEvent") .selectAll() .where("id", "=", eventId) .forUpdate() .executeTakeFirst()) as PersistedEventRow | undefined; // Already planned (at-least-once re-fire) or vanished: no-op. if (!event || event.status !== "PENDING") return undefined; return planPersistedEvent(trx, event, params.adapters, ctx, now); }); if (outcome) { // The plan committed: deliver its outbox rows outside the // transaction. Per-row failures leave QUEUED/PENDING rows for the // redrain cron rather than reverting the plan. await deliverPlanned(db, outcome, params.adapters, now); // Surface why nothing was delivered (emitter contract bug, empty // audience, all recipients muted) instead of silently stamping it. if (outcome.status === "NO_DELIVERY") { logger.warn("notification event stamped NO_DELIVERY", { executor: "dispatch-notification-events", eventId, reason: outcome.reason ?? "no deliverable recipients or destinations", }); } } } catch (error) { logger.error("failed to dispatch notification event", { executor: "dispatch-notification-events", eventId, ...errorAttrs(error), }); throw error; } }, }, }); }