import { describe, expect, it } from "@effect/vitest"; import { Duration, Effect, Fiber } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { PubSub, defaultMessagingRuntimeConfig, makeEffectRequestResponseFromPubSub, MessagingRuntimeLive, makeProducerSpec, runEffectConsumerScoped, runEffectProducerScoped, runFlowScoped, type BackendConsumer, type BackendProducer, type CreateConsumerOptions, type CreateProducerOptions, type FlowContext, type Message, type PubSubBackend, } from "../index.js"; import type { Flow } from "../processor/flow.js"; import { Flow as RuntimeFlow } from "../processor/flow.js"; function createMessage(value: T, properties: Record = {}): Message { return { value: () => value, properties: () => properties, }; } class RecordingProducer implements BackendProducer { readonly sent: Array<{ readonly message: T; readonly properties?: Record }> = []; closeCount = 0; flushCount = 0; constructor(private readonly onSend?: (message: T, properties?: Record) => void) {} async send(message: T, properties?: Record): Promise { this.sent.push(properties === undefined ? { message } : { message, properties }); this.onSend?.(message, properties); } async flush(): Promise { this.flushCount += 1; } async close(): Promise { this.closeCount += 1; } } class ScriptedConsumer implements BackendConsumer { readonly acknowledged: Array> = []; readonly nacked: Array> = []; closeCount = 0; private readonly messages: Array>; constructor(messages: Array> = []) { this.messages = messages; } push(message: Message): void { this.messages.push(message); } async receive(): Promise | null> { const message = this.messages.shift(); if (message !== undefined) { return message; } return null; } async acknowledge(message: Message): Promise { this.acknowledged.push(message); } async negativeAcknowledge(message: Message): Promise { this.nacked.push(message); } async unsubscribe(): Promise {} async close(): Promise { this.closeCount += 1; } } class RuntimeBackend implements PubSubBackend { closeCount = 0; producerOptions: CreateProducerOptions | null = null; consumerOptions: CreateConsumerOptions | null = null; readonly producer: RecordingProducer; constructor( private readonly consumer: BackendConsumer, onSend?: (message: unknown, properties?: Record) => void, ) { this.producer = new RecordingProducer(onSend); } async createProducer(options: CreateProducerOptions): Promise> { this.producerOptions = options; return this.producer as BackendProducer; } async createConsumer(options: CreateConsumerOptions): Promise> { this.consumerOptions = options; return this.consumer as BackendConsumer; } async close(): Promise { this.closeCount += 1; } } const flowContext: FlowContext = { id: "processor", name: "default", flow: {} as Flow, }; describe("Effect-native messaging runtime", () => { it.effect( "creates scoped producers through PubSub and translates send calls", Effect.fnUntraced(function* () { const consumer = new ScriptedConsumer(); const backend = new RuntimeBackend(consumer); yield* Effect.scoped( Effect.gen(function* () { const producer = yield* runEffectProducerScoped({ topic: "tg.test.producer" }); yield* producer.send("message-1", "hello"); expect(backend.producerOptions).toEqual({ topic: "tg.test.producer" }); expect(backend.producer.sent).toEqual([ { message: "hello", properties: { id: "message-1" } }, ]); }).pipe(Effect.provide(PubSub.layer(backend))), ); expect(backend.producer.closeCount).toBe(1); expect(backend.closeCount).toBe(1); }), ); it.effect( "runs consumers as scoped fibers and acknowledges handled messages", Effect.fnUntraced(function* () { const message = createMessage("payload", { id: "request-1" }); const consumer = new ScriptedConsumer([message]); const backend = new RuntimeBackend(consumer as BackendConsumer); const handled: Array = []; yield* Effect.scoped( Effect.gen(function* () { yield* runEffectConsumerScoped( { topic: "tg.test.consumer", subscription: "sub", receiveTimeoutMs: 1, errorBackoffMs: 1, handler: (value, properties) => Effect.sync(() => { handled.push(`${properties.id}:${value}`); }), }, flowContext, ); yield* TestClock.adjust(Duration.millis(20)); }).pipe(Effect.provide(PubSub.layer(backend))), ); expect(handled).toEqual(["request-1:payload"]); expect(consumer.acknowledged).toEqual([message]); expect(consumer.nacked).toEqual([]); expect(consumer.closeCount).toBeGreaterThan(0); }), ); it.effect( "routes request-response replies through an Effect queue", Effect.fnUntraced(function* () { const responseConsumer = new ScriptedConsumer(); const backend = new RuntimeBackend( responseConsumer as BackendConsumer, (_message, properties) => { responseConsumer.push(createMessage("response", { id: properties?.id ?? "" })); }, ); const response = yield* Effect.scoped( Effect.gen(function* () { const requestor = yield* makeEffectRequestResponseFromPubSub( PubSub.fromBackend(backend), { ...defaultMessagingRuntimeConfig, consumerReceiveTimeoutMs: 1, }, { requestTopic: "tg.test.request", responseTopic: "tg.test.response", subscription: "sub", }, ); const fiber = yield* requestor.request("request", { timeoutMs: 250 }).pipe(Effect.forkChild); yield* TestClock.adjust(Duration.millis(5)); return yield* Fiber.join(fiber); }), ); expect(response).toBe("response"); expect(backend.producer.sent[0]?.message).toBe("request"); expect(responseConsumer.acknowledged.length).toBe(1); }), ); it.effect( "fails request-response calls with a typed timeout", Effect.fnUntraced(function* () { const responseConsumer = new ScriptedConsumer(); const backend = new RuntimeBackend(responseConsumer as BackendConsumer); const error = yield* Effect.scoped( Effect.gen(function* () { const requestor = yield* makeEffectRequestResponseFromPubSub( PubSub.fromBackend(backend), { ...defaultMessagingRuntimeConfig, consumerReceiveTimeoutMs: 1, }, { requestTopic: "tg.test.request", responseTopic: "tg.test.response", subscription: "sub", }, ); const fiber = yield* requestor.request("request", { timeoutMs: 5 }).pipe( Effect.flip, Effect.forkChild, ); yield* TestClock.adjust(Duration.millis(10)); return yield* Fiber.join(fiber); }), ); expect(error._tag).toBe("MessagingTimeoutError"); expect(error.operation).toBe("request-response"); expect(error.timeoutMs).toBe(5); }), ); it.effect( "owns Flow lifecycle through a scoped Effect boundary", Effect.fnUntraced(function* () { const consumer = new ScriptedConsumer(); const backend = new RuntimeBackend(consumer); const flow = new RuntimeFlow( "flow-a", "processor", backend, {}, [makeProducerSpec("flow-output")], ); yield* Effect.scoped( runFlowScoped(flow).pipe( Effect.provide(MessagingRuntimeLive), Effect.provideService(PubSub, PubSub.fromBackend(backend)), ), ); expect(backend.producerOptions).toEqual({ topic: "flow-output" }); expect(backend.producer.closeCount).toBe(1); }), ); });