feat(core-events): PayloadJobsEventBus (fan-out via IJobQueue)
TDD: red (test-only), then green. PayloadJobsEventBus validates before enqueueing, names tasks __events.<event>.<consumer> deterministically, and enqueues one task per subscriber via Promise.all. 3 new tests (11 total). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
51
packages/core-events/src/payload-jobs-event-bus.test.ts
Normal file
51
packages/core-events/src/payload-jobs-event-bus.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineEvent } from "@/event-descriptor";
|
||||
import { PayloadJobsEventBus } from "@/payload-jobs-event-bus";
|
||||
import type { IJobQueue } from "@repo/core-shared/jobs";
|
||||
|
||||
const evt = defineEvent("auth.user.signed-up", z.object({ userId: z.string() }).strict());
|
||||
|
||||
function recordingQueue(): IJobQueue & { enqueued: { taskSlug: string; input: unknown }[] } {
|
||||
const enqueued: { taskSlug: string; input: unknown }[] = [];
|
||||
const q: IJobQueue = {
|
||||
async enqueue(taskSlug, input) {
|
||||
enqueued.push({ taskSlug, input });
|
||||
return { jobId: `recording-${enqueued.length}` };
|
||||
},
|
||||
};
|
||||
return Object.assign(q, { enqueued });
|
||||
}
|
||||
|
||||
describe("PayloadJobsEventBus", () => {
|
||||
it("validates the payload before enqueueing", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
bus.subscribe(evt, "marketing-pages", vi.fn());
|
||||
await expect(
|
||||
bus.publish(evt, { userId: 42 } as unknown as { userId: string }),
|
||||
).rejects.toThrow();
|
||||
expect(queue.enqueued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enqueues one task per subscriber, naming `__events.<event>.<consumer>`", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
bus.subscribe(evt, "marketing-pages", vi.fn());
|
||||
bus.subscribe(evt, "blog", vi.fn());
|
||||
await bus.publish(evt, { userId: "u1" });
|
||||
expect(queue.enqueued).toHaveLength(2);
|
||||
expect(queue.enqueued.map((e) => e.taskSlug).sort()).toEqual([
|
||||
"__events.auth.user.signed-up.blog",
|
||||
"__events.auth.user.signed-up.marketing-pages",
|
||||
]);
|
||||
expect(queue.enqueued[0]!.input).toEqual({ userId: "u1" });
|
||||
});
|
||||
|
||||
it("enqueues nothing when no subscribers are registered", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
await bus.publish(evt, { userId: "u1" });
|
||||
expect(queue.enqueued).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
43
packages/core-events/src/payload-jobs-event-bus.ts
Normal file
43
packages/core-events/src/payload-jobs-event-bus.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { z } from "zod";
|
||||
import type { IJobQueue } from "@repo/core-shared/jobs";
|
||||
import type { EventDescriptor } from "./event-descriptor";
|
||||
import type { EventHandler, IEventBus } from "./event-bus.interface";
|
||||
|
||||
/**
|
||||
* Production-grade bus: for each subscriber, enqueues one Payload task per
|
||||
* `publish()` call. Subscribers register with their consumer-feature name so
|
||||
* fan-out tasks are named deterministically: `__events.<event>.<consumer>`.
|
||||
* The actual handler invocation happens inside Payload's job runner — see the
|
||||
* matching task config generated by `gen event consume` (Task 39).
|
||||
*/
|
||||
export class PayloadJobsEventBus implements IEventBus {
|
||||
private readonly subscribers = new Map<string, string[]>();
|
||||
|
||||
constructor(private readonly queue: IJobQueue) {}
|
||||
|
||||
async publish<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
payload: T,
|
||||
): Promise<void> {
|
||||
descriptor.schema.parse(payload);
|
||||
const consumers = this.subscribers.get(descriptor.name) ?? [];
|
||||
await Promise.all(
|
||||
consumers.map((consumerFeature) =>
|
||||
this.queue.enqueue(
|
||||
`__events.${descriptor.name}.${consumerFeature}`,
|
||||
payload,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
subscribe<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
consumerFeature: string,
|
||||
_handler: EventHandler<T>,
|
||||
): void {
|
||||
const arr = this.subscribers.get(descriptor.name) ?? [];
|
||||
if (!arr.includes(consumerFeature)) arr.push(consumerFeature);
|
||||
this.subscribers.set(descriptor.name, arr);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user