Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
})
})
10 changes: 10 additions & 0 deletions apps/web/src/lib/server/events/event-dispatch-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -131,6 +132,15 @@ export async function runEventDispatch(
job: ClaimedJob,
deps: EventDispatchDeps = {}
): Promise<void> {
// 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

Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/server/events/resolvers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ export async function resolveTargets(
event: DomainEvent,
opts: { bestEffort?: boolean } = {}
): Promise<HookTarget[]> {
// 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
Expand Down