diff --git a/apps/web/src/lib/server/events/__tests__/dispatch-registry-wiring.test.ts b/apps/web/src/lib/server/events/__tests__/dispatch-registry-wiring.test.ts new file mode 100644 index 0000000000..ff9679fdd0 --- /dev/null +++ b/apps/web/src/lib/server/events/__tests__/dispatch-registry-wiring.test.ts @@ -0,0 +1,58 @@ +/** + * Contract + * + * V1 Every sink that registerAllResolvers() knows about takes part in + * production fan-out: an event the dispatcher processes is resolved + * against a POPULATED registry, never an empty one. + * + * Regression guard. The WO-18 cutover removed the last production caller of + * getHookTargets(), and with it the only call to registerAllResolvers(). The + * registry stayed empty for the whole process lifetime, so resolveTargets() + * returned [] for every event: the row was stamped published, no hook job was + * ever enqueued, and no sink fired at all — integrations, webhooks, + * notifications, AI, workflows. Nothing logged, nothing errored. + * + * Deliberately DB-free: the handler registers before it touches the event row, + * so a payload without an eventId exercises the wiring and returns early. + */ +import { describe, expect, it } from 'vitest' +import { createId } from '@quackback/ids' +import { runEventDispatch } from '../event-dispatch-queue' +import { listResolvers } from '../resolvers/registry' +import type { ClaimedJob } from '@/lib/server/jobs/job-queue' + +/** A claimed job whose payload carries no eventId — the handler returns before any DB access. */ +function jobWithoutEvent(): ClaimedJob { + return { + id: '1', + jobId: createId('job'), + queue: 'event-dispatch', + dedupeKey: null, + payload: {}, + workspaceKey: null, + attempts: 1, + maxAttempts: 10, + leaseToken: 'test', + lockedUntil: new Date(), + } +} + +describe('event-dispatch resolves against a populated sink registry', () => { + it('registers the sinks before resolving (V1)', async () => { + await runEventDispatch(jobWithoutEvent()) + + // Not pinned to an exact set — a ninth resolver is a legitimate change, an + // empty registry never is. + expect(listResolvers().length).toBeGreaterThan(0) + expect(listResolvers().map((r) => r.sink)).toContain('integration') + }) + + it('stays populated across dispatches without duplicating registrations (V1)', async () => { + await runEventDispatch(jobWithoutEvent()) + const afterFirst = listResolvers().length + + await runEventDispatch(jobWithoutEvent()) + + expect(listResolvers()).toHaveLength(afterFirst) + }) +}) diff --git a/apps/web/src/lib/server/events/event-dispatch-queue.ts b/apps/web/src/lib/server/events/event-dispatch-queue.ts index 9077640164..66cd687ae9 100644 --- a/apps/web/src/lib/server/events/event-dispatch-queue.ts +++ b/apps/web/src/lib/server/events/event-dispatch-queue.ts @@ -17,6 +17,7 @@ import { SINGLE_WORKSPACE_KEY } from '@/lib/server/workspaces/after-commit' import { getCurrentWorkspace } from '@/lib/server/workspaces/workspace-context' import { enqueueHookJobsWithIds } from './process' import { hydrateEvent, MAX_DEPTH, MAX_STRICT_RESOLVE_ATTEMPTS } from './outbox' +import { registerAllResolvers } from './resolvers' import { resolveTargets } from './resolvers/registry' import { toLegacyEvent } from './to-legacy-event' import crypto from 'crypto' @@ -131,6 +132,15 @@ export async function runEventDispatch( job: ClaimedJob, deps: EventDispatchDeps = {} ): Promise { + // Fill the sink registry before the first resolve. `resolveTargets` reads a + // module-level array that only `registerAllResolvers()` populates, and its + // former caller — `getHookTargets()` in targets.ts — lost its last production + // call site in the WO-18 cutover. Nothing filled the registry any more, so + // every event resolved to zero targets: published, no hook jobs, no error. + // Idempotent, and the import above is static on purpose: a call-time + // `import()` here would load the resolver graph inside a per-pass workspace + // scope (see jobs/__tests__/handler-imports.test.ts). + if (!deps.resolve) registerAllResolvers() const resolve = deps.resolve ?? resolveTargets const enqueue = deps.enqueue ?? enqueueHookJobsWithIds diff --git a/apps/web/src/lib/server/events/resolvers/registry.ts b/apps/web/src/lib/server/events/resolvers/registry.ts index 9669465464..350f61c48b 100644 --- a/apps/web/src/lib/server/events/resolvers/registry.ts +++ b/apps/web/src/lib/server/events/resolvers/registry.ts @@ -45,6 +45,16 @@ export async function resolveTargets( event: DomainEvent, opts: { bestEffort?: boolean } = {} ): Promise { + // An empty registry means nothing ever called registerAllResolvers(). Every + // event then resolves to zero targets and is stamped published without firing + // a single sink — the exact silent outage this log exists to name. + if (resolvers.length === 0) { + log.error( + { type: event.type, event_id: event.eventId }, + 'sink registry is empty — no resolver registered, this event fans out to nothing' + ) + } + const interested = resolvers.filter((r) => r.interestedIn(event.type)) // One fan-out for both modes; only the rejection handling differs. allSettled // is equivalent to Promise.all here (all doesn't cancel siblings either) and