chore/port-solidtime-audit-fixes #1

Merged
danijel merged 54 commits from chore/port-solidtime-audit-fixes into main 2026-07-12 21:13:09 +00:00
71 changed files with 1778 additions and 644 deletions
Showing only changes of commit 27787193c0 - Show all commits

View File

@@ -2,7 +2,10 @@ export { RecordingTracer, type RecordedSpan } from "./recording-tracer";
export { RecordingLogger, type RecordedCapture } from "./recording-logger"; export { RecordingLogger, type RecordedCapture } from "./recording-logger";
export { RecordingMetrics, type RecordedMetric } from "./recording-metrics"; export { RecordingMetrics, type RecordedMetric } from "./recording-metrics";
export { RecordingJobQueue } from "./recording-job-queue"; export { RecordingJobQueue } from "./recording-job-queue";
export { RecordingEventBus } from "./recording-event-bus"; export {
RecordingEventBus,
type RecordingEventBusOptions,
} from "./recording-event-bus";
export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster"; export { RecordingRealtimeBroadcaster } from "./recording-realtime-broadcaster";
export { RecordingAuditLog } from "./recording-audit-log"; export { RecordingAuditLog } from "./recording-audit-log";
export { export {

View File

@@ -28,7 +28,7 @@ describe("RecordingEventBus", () => {
expect(bus.published).toHaveLength(0); expect(bus.published).toHaveLength(0);
}); });
it("invokes registered handlers sequentially in subscription order", async () => { it("invokes all registered handlers (started in subscription order)", async () => {
const bus = new RecordingEventBus(); const bus = new RecordingEventBus();
const order: string[] = []; const order: string[] = [];
bus.subscribe(evt, "consumer-a", async () => { bus.subscribe(evt, "consumer-a", async () => {
@@ -40,4 +40,48 @@ describe("RecordingEventBus", () => {
await bus.publish(evt, { id: "x" }); await bus.publish(evt, { id: "x" });
expect(order).toEqual(["a", "b"]); expect(order).toEqual(["a", "b"]);
}); });
it("delivers and records the zod-parsed payload, not the raw input", async () => {
const evtNormalized = {
name: "test.normalized" as const,
schema: z.object({ id: z.string(), mode: z.string().catch("auto") }),
};
const bus = new RecordingEventBus();
const received: unknown[] = [];
bus.subscribe(evtNormalized, "consumer", async (e) => {
received.push(e);
});
await bus.publish(evtNormalized, {
id: "x",
mode: 42 as unknown as string,
});
expect(received).toEqual([{ id: "x", mode: "auto" }]);
expect(bus.published).toEqual([
{ name: "test.normalized", payload: { id: "x", mode: "auto" } },
]);
});
it("swallows handler errors by default, like the real InMemoryEventBus", async () => {
const bus = new RecordingEventBus();
const order: string[] = [];
bus.subscribe(evt, "boom", async () => {
throw new Error("subscriber blew up");
});
bus.subscribe(evt, "after-boom", async () => {
order.push("after-boom");
});
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
expect(order).toEqual(["after-boom"]); // fan-out not short-circuited
expect(bus.published).toHaveLength(1);
});
it("rethrows the first handler error when failFast is true", async () => {
const bus = new RecordingEventBus({ failFast: true });
bus.subscribe(evt, "first", async () => {
throw new Error("first failure");
});
await expect(bus.publish(evt, { id: "x" })).rejects.toThrow(
"first failure",
);
});
}); });

View File

@@ -22,17 +22,38 @@ interface IEventBus {
): void; ): void;
} }
export type RecordingEventBusOptions = {
/** When true, rethrow the first handler error (default: false — errors swallowed). */
failFast?: boolean;
};
/**
* Recording test double for IEventBus. Delivery semantics deliberately mirror
* the real InMemoryEventBus (@repo/core-events, scaffolded via
* `gen core-package events`): handlers receive the zod-PARSED payload
* (defaults/coercions applied), fan-out uses Promise.allSettled, and handler
* errors are swallowed unless `failFast`.
*/
export class RecordingEventBus implements IEventBus { export class RecordingEventBus implements IEventBus {
readonly published: { name: string; payload: unknown }[] = []; readonly published: { name: string; payload: unknown }[] = [];
private readonly handlers = new Map<string, EventHandler<unknown>[]>(); private readonly handlers = new Map<string, EventHandler<unknown>[]>();
constructor(private readonly options: RecordingEventBusOptions = {}) {}
async publish<T>( async publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>, descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T, payload: T,
): Promise<void> { ): Promise<void> {
descriptor.schema.parse(payload); const parsed = descriptor.schema.parse(payload);
this.published.push({ name: descriptor.name, payload }); this.published.push({ name: descriptor.name, payload: parsed });
for (const h of this.handlers.get(descriptor.name) ?? []) await h(payload); const subscribers = this.handlers.get(descriptor.name) ?? [];
if (subscribers.length === 0) return;
const settled = await Promise.allSettled(subscribers.map((h) => h(parsed)));
if (this.options.failFast) {
const failure = settled.find((s) => s.status === "rejected");
// Only the first rejection is rethrown — matching InMemoryEventBus.
if (failure && failure.status === "rejected") throw failure.reason;
}
} }
subscribe<T>( subscribe<T>(