mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
refactor(ts): make port effect native
This commit is contained in:
parent
2868ced2d3
commit
b6759e75df
113 changed files with 4140 additions and 4554 deletions
|
|
@ -20,19 +20,19 @@
|
|||
"test": "bunx --bun vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/ai-anthropic": "4.0.0-beta.75",
|
||||
"@effect/ai-openai": "4.0.0-beta.75",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.75",
|
||||
"@effect/atom-react": "4.0.0-beta.75",
|
||||
"@effect/openapi-generator": "4.0.0-beta.75",
|
||||
"@effect/opentelemetry": "4.0.0-beta.75",
|
||||
"@effect/platform-browser": "4.0.0-beta.75",
|
||||
"@effect/platform-bun": "4.0.0-beta.75",
|
||||
"effect": "4.0.0-beta.75",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.78",
|
||||
"@effect/ai-openai": "4.0.0-beta.78",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.78",
|
||||
"@effect/atom-react": "4.0.0-beta.78",
|
||||
"@effect/openapi-generator": "4.0.0-beta.78",
|
||||
"@effect/opentelemetry": "4.0.0-beta.78",
|
||||
"@effect/platform-browser": "4.0.0-beta.78",
|
||||
"@effect/platform-bun": "4.0.0-beta.78",
|
||||
"effect": "4.0.0-beta.78",
|
||||
"nats": "^2.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.75",
|
||||
"@effect/vitest": "4.0.0-beta.78",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Effect } from "effect";
|
||||
import { makeConsumer, type ConsumerOptions, type FlowContext } from "../messaging/consumer.js";
|
||||
import type {
|
||||
PubSubBackend,
|
||||
|
|
@ -25,14 +26,17 @@ function createMockBackendConsumer<T>(): BackendConsumer<T> & {
|
|||
acknowledge: ReturnType<typeof vi.fn>;
|
||||
negativeAcknowledge: ReturnType<typeof vi.fn>;
|
||||
unsubscribe: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
close: Effect.Effect<void>;
|
||||
closeMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const closeMock = vi.fn();
|
||||
return {
|
||||
receive: vi.fn().mockResolvedValue(null),
|
||||
acknowledge: vi.fn().mockResolvedValue(undefined),
|
||||
negativeAcknowledge: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
receive: vi.fn().mockReturnValue(Effect.succeed(null)),
|
||||
acknowledge: vi.fn().mockReturnValue(Effect.void),
|
||||
negativeAcknowledge: vi.fn().mockReturnValue(Effect.void),
|
||||
unsubscribe: vi.fn().mockReturnValue(Effect.void),
|
||||
close: Effect.sync(closeMock),
|
||||
closeMock,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -41,9 +45,9 @@ function createMockPubSub<T>(
|
|||
backendConsumer: BackendConsumer<T>,
|
||||
): PubSubBackend {
|
||||
return {
|
||||
createProducer: vi.fn().mockResolvedValue({} as BackendProducer<unknown>),
|
||||
createConsumer: vi.fn().mockResolvedValue(backendConsumer),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
createProducer: vi.fn().mockReturnValue(Effect.succeed({} as BackendProducer<unknown>)),
|
||||
createConsumer: vi.fn().mockReturnValue(Effect.succeed(backendConsumer)),
|
||||
close: Effect.void,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +98,7 @@ describe("Consumer", () => {
|
|||
|
||||
expect(consumer).toMatchObject({
|
||||
start: expect.any(Function),
|
||||
stop: expect.any(Function),
|
||||
stop: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -111,13 +115,13 @@ describe("Consumer", () => {
|
|||
|
||||
expect(consumer).toMatchObject({
|
||||
start: expect.any(Function),
|
||||
stop: expect.any(Function),
|
||||
stop: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
// ── start() creates consumer and calls handler ─────────────────
|
||||
it("starts a scoped consumer and invokes handler for received messages", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
const handler = vi.fn().mockReturnValue(Effect.void);
|
||||
const msg = createMockMessage({ data: "hello" }, { id: "1" });
|
||||
|
||||
const consumer = makeConsumer({
|
||||
|
|
@ -127,11 +131,11 @@ describe("Consumer", () => {
|
|||
handler,
|
||||
});
|
||||
|
||||
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
|
||||
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
|
||||
|
||||
await consumer.start(flowCtx);
|
||||
await Effect.runPromise(consumer.start(flowCtx));
|
||||
await advanceUntil(() => handler.mock.calls.length > 0);
|
||||
await consumer.stop();
|
||||
await Effect.runPromise(consumer.stop);
|
||||
|
||||
expect(pubsub.createConsumer).toHaveBeenCalledWith({
|
||||
topic: "topic-a",
|
||||
|
|
@ -143,7 +147,7 @@ describe("Consumer", () => {
|
|||
|
||||
// ── Messages are acknowledged after successful handling ────────
|
||||
it("acknowledges messages after successful handling", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
const handler = vi.fn().mockReturnValue(Effect.void);
|
||||
const msg = createMockMessage("payload");
|
||||
|
||||
const consumer = makeConsumer({
|
||||
|
|
@ -153,11 +157,11 @@ describe("Consumer", () => {
|
|||
handler,
|
||||
});
|
||||
|
||||
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
|
||||
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
|
||||
|
||||
await consumer.start(flowCtx);
|
||||
await Effect.runPromise(consumer.start(flowCtx));
|
||||
await advanceUntil(() => backendConsumer.acknowledge.mock.calls.length > 0);
|
||||
await consumer.stop();
|
||||
await Effect.runPromise(consumer.stop);
|
||||
|
||||
expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg);
|
||||
expect(backendConsumer.negativeAcknowledge).not.toHaveBeenCalled();
|
||||
|
|
@ -165,7 +169,7 @@ describe("Consumer", () => {
|
|||
|
||||
// ── Messages are negatively acknowledged on handler error ──────
|
||||
it("negatively acknowledges messages when the handler throws", async () => {
|
||||
const handler = vi.fn().mockRejectedValue("handler boom");
|
||||
const handler = vi.fn().mockReturnValue(Effect.fail("handler boom"));
|
||||
const msg = createMockMessage("bad-payload");
|
||||
|
||||
const consumer = makeConsumer({
|
||||
|
|
@ -175,11 +179,11 @@ describe("Consumer", () => {
|
|||
handler,
|
||||
});
|
||||
|
||||
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
|
||||
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
|
||||
|
||||
await consumer.start(flowCtx);
|
||||
await Effect.runPromise(consumer.start(flowCtx));
|
||||
await advanceUntil(() => backendConsumer.negativeAcknowledge.mock.calls.length > 0);
|
||||
await consumer.stop();
|
||||
await Effect.runPromise(consumer.stop);
|
||||
|
||||
expect(backendConsumer.negativeAcknowledge).toHaveBeenCalledWith(msg);
|
||||
expect(backendConsumer.acknowledge).not.toHaveBeenCalled();
|
||||
|
|
@ -188,12 +192,17 @@ describe("Consumer", () => {
|
|||
// ── TooManyRequestsError triggers retry ────────────────────────
|
||||
it("retries the handler on TooManyRequestsError", async () => {
|
||||
let handlerCalls = 0;
|
||||
const handler = vi.fn().mockImplementation(async () => {
|
||||
handlerCalls++;
|
||||
if (handlerCalls === 1) {
|
||||
throw tooManyRequestsError("rate limited");
|
||||
}
|
||||
// Second call succeeds
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
return Effect.sync(() => {
|
||||
handlerCalls++;
|
||||
return handlerCalls;
|
||||
}).pipe(
|
||||
Effect.flatMap((attempt) =>
|
||||
attempt === 1
|
||||
? Effect.fail(tooManyRequestsError("rate limited"))
|
||||
: Effect.void
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const msg = createMockMessage("rate-limited-payload");
|
||||
|
|
@ -206,17 +215,17 @@ describe("Consumer", () => {
|
|||
rateLimitRetryMs: 500,
|
||||
});
|
||||
|
||||
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
|
||||
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await consumer.start(flowCtx);
|
||||
await Effect.runPromise(consumer.start(flowCtx));
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
await advanceUntil(() => handler.mock.calls.length >= 2);
|
||||
await consumer.stop();
|
||||
await Effect.runPromise(consumer.stop);
|
||||
|
||||
// Handler called twice: first throws TooManyRequestsError, second succeeds
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handlerCalls).toBe(2);
|
||||
// Message should be acknowledged (retry succeeded)
|
||||
expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg);
|
||||
|
||||
|
|
@ -225,11 +234,17 @@ describe("Consumer", () => {
|
|||
|
||||
it("retries repeated TooManyRequestsError until success within the timeout", async () => {
|
||||
let handlerCalls = 0;
|
||||
const handler = vi.fn().mockImplementation(async () => {
|
||||
handlerCalls++;
|
||||
if (handlerCalls <= 2) {
|
||||
throw tooManyRequestsError("rate limited");
|
||||
}
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
return Effect.sync(() => {
|
||||
handlerCalls++;
|
||||
return handlerCalls;
|
||||
}).pipe(
|
||||
Effect.flatMap((attempt) =>
|
||||
attempt <= 2
|
||||
? Effect.fail(tooManyRequestsError("rate limited"))
|
||||
: Effect.void
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const msg = createMockMessage("rate-limited-payload");
|
||||
|
|
@ -243,22 +258,27 @@ describe("Consumer", () => {
|
|||
rateLimitTimeoutMs: 2_000,
|
||||
});
|
||||
|
||||
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
|
||||
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
|
||||
|
||||
await consumer.start(flowCtx);
|
||||
await Effect.runPromise(consumer.start(flowCtx));
|
||||
await vi.advanceTimersByTimeAsync(1_100);
|
||||
await advanceUntil(() => backendConsumer.acknowledge.mock.calls.length > 0);
|
||||
await consumer.stop();
|
||||
await Effect.runPromise(consumer.stop);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(3);
|
||||
expect(handlerCalls).toBe(3);
|
||||
expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg);
|
||||
expect(backendConsumer.negativeAcknowledge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("negatively acknowledges when rate-limit retry timeout elapses", async () => {
|
||||
const handler = vi.fn().mockImplementation(async () => {
|
||||
throw tooManyRequestsError("rate limited");
|
||||
});
|
||||
let handlerCalls = 0;
|
||||
const handler = vi.fn().mockReturnValue(
|
||||
Effect.sync(() => {
|
||||
handlerCalls++;
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Effect.fail(tooManyRequestsError("rate limited"))),
|
||||
),
|
||||
);
|
||||
const msg = createMockMessage("rate-limited-payload");
|
||||
|
||||
const consumer = makeConsumer({
|
||||
|
|
@ -270,21 +290,21 @@ describe("Consumer", () => {
|
|||
rateLimitTimeoutMs: 1_000,
|
||||
});
|
||||
|
||||
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
|
||||
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
|
||||
|
||||
await consumer.start(flowCtx);
|
||||
await Effect.runPromise(consumer.start(flowCtx));
|
||||
await vi.advanceTimersByTimeAsync(1_100);
|
||||
await advanceUntil(() => backendConsumer.negativeAcknowledge.mock.calls.length > 0);
|
||||
await consumer.stop();
|
||||
await Effect.runPromise(consumer.stop);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handlerCalls).toBeGreaterThanOrEqual(2);
|
||||
expect(backendConsumer.negativeAcknowledge).toHaveBeenCalledWith(msg);
|
||||
expect(backendConsumer.acknowledge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── stop() closes the backend ──────────────────────────────────
|
||||
it("stop() sets running=false and closes the backend", async () => {
|
||||
backendConsumer.receive.mockResolvedValue(null);
|
||||
backendConsumer.receive.mockReturnValue(Effect.succeed(null));
|
||||
|
||||
const consumer = makeConsumer({
|
||||
pubsub,
|
||||
|
|
@ -293,10 +313,10 @@ describe("Consumer", () => {
|
|||
handler: vi.fn(),
|
||||
});
|
||||
|
||||
await consumer.start(flowCtx);
|
||||
await consumer.stop();
|
||||
await Effect.runPromise(consumer.start(flowCtx));
|
||||
await Effect.runPromise(consumer.stop);
|
||||
|
||||
expect(backendConsumer.close).toHaveBeenCalled();
|
||||
await expect(consumer.stop()).resolves.toBeUndefined();
|
||||
expect(backendConsumer.closeMock).toHaveBeenCalled();
|
||||
await expect(Effect.runPromise(consumer.stop)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,13 +62,15 @@ const waitFor = (condition: () => boolean, label: string) =>
|
|||
class RecordingProducer<T> implements BackendProducer<T> {
|
||||
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {}
|
||||
readonly flush: Effect.Effect<void> = Effect.void;
|
||||
|
||||
async close(): Promise<void> {}
|
||||
readonly close: Effect.Effect<void> = Effect.void;
|
||||
}
|
||||
|
||||
class PushConsumer<T> implements BackendConsumer<T> {
|
||||
|
|
@ -87,32 +89,38 @@ class PushConsumer<T> implements BackendConsumer<T> {
|
|||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || this.closed) {
|
||||
return message ?? null;
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
receive(): Effect.Effect<Message<T> | null> {
|
||||
return Effect.promise(() => {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || this.closed) {
|
||||
return Promise.resolve(message ?? null);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async acknowledge(message: Message<T>): Promise<void> {
|
||||
this.acknowledged.push(message);
|
||||
acknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.acknowledged.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async negativeAcknowledge(message: Message<T>): Promise<void> {
|
||||
this.nacked.push(message);
|
||||
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.nacked.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(): Promise<void> {}
|
||||
readonly unsubscribe: Effect.Effect<void> = Effect.void;
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closed = true;
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
waiter(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class EmbeddingsBackend implements PubSubBackend {
|
||||
|
|
@ -121,24 +129,28 @@ class EmbeddingsBackend implements PubSubBackend {
|
|||
readonly producersByTopic = new Map<string, RecordingProducer<unknown>>();
|
||||
closeCount = 0;
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
const producer = new RecordingProducer<unknown>();
|
||||
this.producersByTopic.set(options.topic, producer);
|
||||
return producer as BackendProducer<T>;
|
||||
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.sync(() => {
|
||||
const producer = new RecordingProducer<unknown>();
|
||||
this.producersByTopic.set(options.topic, producer);
|
||||
return producer as BackendProducer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
if (options.topic === topics.configPush) {
|
||||
return this.configConsumer as unknown as BackendConsumer<T>;
|
||||
}
|
||||
const consumer = new PushConsumer<unknown>();
|
||||
this.consumersByTopic.set(options.topic, consumer);
|
||||
return consumer as BackendConsumer<T>;
|
||||
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
|
||||
return Effect.sync(() => {
|
||||
if (options.topic === topics.configPush) {
|
||||
return this.configConsumer as unknown as BackendConsumer<T>;
|
||||
}
|
||||
const consumer = new PushConsumer<unknown>();
|
||||
this.consumersByTopic.set(options.topic, consumer);
|
||||
return consumer as BackendConsumer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
pushConfig(): void {
|
||||
this.configConsumer.push(
|
||||
|
|
|
|||
|
|
@ -58,17 +58,19 @@ class RecordingProducer<T> implements BackendProducer<T> {
|
|||
closeCount = 0;
|
||||
flushCount = 0;
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
readonly flush: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.flushCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class PushConsumer<T> implements BackendConsumer<T> {
|
||||
|
|
@ -88,33 +90,39 @@ class PushConsumer<T> implements BackendConsumer<T> {
|
|||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || this.closed) {
|
||||
return message ?? null;
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
receive(): Effect.Effect<Message<T> | null> {
|
||||
return Effect.promise(() => {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || this.closed) {
|
||||
return Promise.resolve(message ?? null);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async acknowledge(message: Message<T>): Promise<void> {
|
||||
this.acknowledged.push(message);
|
||||
acknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.acknowledged.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async negativeAcknowledge(message: Message<T>): Promise<void> {
|
||||
this.nacked.push(message);
|
||||
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.nacked.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(): Promise<void> {}
|
||||
readonly unsubscribe: Effect.Effect<void> = Effect.void;
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closed = true;
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
waiter(null);
|
||||
}
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class FlowProcessorBackend implements PubSubBackend {
|
||||
|
|
@ -124,24 +132,28 @@ class FlowProcessorBackend implements PubSubBackend {
|
|||
readonly producers: Array<RecordingProducer<unknown>> = [];
|
||||
closeCount = 0;
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
this.producerOptions.push(options);
|
||||
const producer = new RecordingProducer<unknown>();
|
||||
this.producers.push(producer);
|
||||
return producer as BackendProducer<T>;
|
||||
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.producerOptions.push(options);
|
||||
const producer = new RecordingProducer<unknown>();
|
||||
this.producers.push(producer);
|
||||
return producer as BackendProducer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
this.consumerOptions.push(options);
|
||||
if (options.topic === topics.configPush) {
|
||||
return this.configConsumer as unknown as BackendConsumer<T>;
|
||||
}
|
||||
return new PushConsumer<T>();
|
||||
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.consumerOptions.push(options);
|
||||
if (options.topic === topics.configPush) {
|
||||
return this.configConsumer as unknown as BackendConsumer<T>;
|
||||
}
|
||||
return new PushConsumer<T>();
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
pushConfig(version: number, flows: Record<string, unknown>): void {
|
||||
this.pushFlowConfig(version, flows);
|
||||
|
|
@ -159,9 +171,9 @@ class TestFlowProcessor extends FlowProcessor {
|
|||
) {
|
||||
super(config);
|
||||
this.registerSpecification(makeProducerSpec<string>("output"));
|
||||
this.registerConfigHandler(async (_config, version) => {
|
||||
this.registerConfigHandler((_config, version) => Effect.sync(() => {
|
||||
this.events.push(`handler:${version}`);
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import * as S from "effect/Schema";
|
|||
import * as TestClock from "effect/testing/TestClock";
|
||||
import {
|
||||
makeConsumerSpec,
|
||||
makeConsumerSpecFromPromise,
|
||||
Flow,
|
||||
MessagingRuntimeLive,
|
||||
makeParameterSpec,
|
||||
|
|
@ -34,18 +33,20 @@ class RecordingProducer<T> implements BackendProducer<T> {
|
|||
|
||||
constructor(private readonly onSend?: (message: T, properties?: Record<string, string>) => void) {}
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
this.onSend?.(message, properties);
|
||||
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
this.onSend?.(message, properties);
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
readonly flush: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.flushCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class ScriptedConsumer<T> implements BackendConsumer<T> {
|
||||
|
|
@ -72,33 +73,39 @@ class ScriptedConsumer<T> implements BackendConsumer<T> {
|
|||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || !this.waitForMessages || this.closed) {
|
||||
return message ?? null;
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
receive(): Effect.Effect<Message<T> | null> {
|
||||
return Effect.promise(() => {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || !this.waitForMessages || this.closed) {
|
||||
return Promise.resolve(message ?? null);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async acknowledge(message: Message<T>): Promise<void> {
|
||||
this.acknowledged.push(message);
|
||||
acknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.acknowledged.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async negativeAcknowledge(message: Message<T>): Promise<void> {
|
||||
this.nacked.push(message);
|
||||
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.nacked.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(): Promise<void> {}
|
||||
readonly unsubscribe: Effect.Effect<void> = Effect.void;
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closed = true;
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
waiter(null);
|
||||
}
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class RuntimeBackend implements PubSubBackend {
|
||||
|
|
@ -114,19 +121,23 @@ class RuntimeBackend implements PubSubBackend {
|
|||
this.producer = new RecordingProducer<unknown>(onSend);
|
||||
}
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
this.producerOptions = options;
|
||||
return this.producer as BackendProducer<T>;
|
||||
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.producerOptions = options;
|
||||
return this.producer as BackendProducer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
this.consumerOptions = options;
|
||||
return this.consumer as BackendConsumer<T>;
|
||||
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.consumerOptions = options;
|
||||
return this.consumer as BackendConsumer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fastMessagingConfig = ConfigProvider.layer(
|
||||
|
|
@ -187,7 +198,7 @@ describe("Effect-native flow specifications", () => {
|
|||
);
|
||||
|
||||
it.effect(
|
||||
"runs Promise handlers through the explicit makeConsumerSpec compatibility helper",
|
||||
"runs Effect handlers through makeConsumerSpec",
|
||||
Effect.fnUntraced(function* () {
|
||||
const message = createMessage("payload", { id: "request-1" });
|
||||
const consumer = new ScriptedConsumer<string>([message]);
|
||||
|
|
@ -199,11 +210,11 @@ describe("Effect-native flow specifications", () => {
|
|||
backend,
|
||||
{},
|
||||
[
|
||||
makeConsumerSpecFromPromise<string>(
|
||||
makeConsumerSpec<string>(
|
||||
"input",
|
||||
async (value, properties, flowContext: FlowContext) => {
|
||||
(value, properties, flowContext: FlowContext) => Effect.sync(() => {
|
||||
handled.push(`${flowContext.name}:${properties.id}:${value}`);
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -226,7 +237,7 @@ describe("Effect-native flow specifications", () => {
|
|||
);
|
||||
|
||||
it.effect(
|
||||
"registers request-response specs through Effect queues and keeps the Promise facade working",
|
||||
"registers request-response specs through Effect queues and exposes Effect requestors",
|
||||
Effect.fnUntraced(function* () {
|
||||
const responseConsumer = new ScriptedConsumer<string>([], true);
|
||||
const backend = new RuntimeBackend(
|
||||
|
|
@ -257,10 +268,8 @@ describe("Effect-native flow specifications", () => {
|
|||
yield* flow.startEffect;
|
||||
const duplicateSpecError = yield* flow.requestorEffect(duplicateRequestResponseSpec).pipe(Effect.flip);
|
||||
expect(duplicateSpecError._tag).toBe("FlowResourceNotFoundError");
|
||||
const requestor = flow.requestor(requestResponseSpec);
|
||||
const fiber = yield* Effect.promise(() =>
|
||||
requestor.request("request", { timeoutMs: 250 }),
|
||||
).pipe(Effect.forkChild);
|
||||
const requestor = yield* flow.requestor(requestResponseSpec);
|
||||
const fiber = yield* requestor.request("request", { timeoutMs: 250 }).pipe(Effect.forkChild);
|
||||
yield* TestClock.adjust(Duration.millis(5));
|
||||
return yield* Fiber.join(fiber);
|
||||
}),
|
||||
|
|
@ -299,7 +308,8 @@ describe("Effect-native flow specifications", () => {
|
|||
const legacyParameter = yield* flow.parameterEffect("present");
|
||||
const parameterError = yield* flow.parameterEffect("missing-parameter").pipe(Effect.flip);
|
||||
const invalidParameterError = yield* flow.parameterEffect(invalidParameter).pipe(Effect.flip);
|
||||
return { producerError, parameter, legacyParameter, parameterError, invalidParameterError };
|
||||
const legacyProducerError = yield* flow.producer("missing-producer").pipe(Effect.flip);
|
||||
return { producerError, legacyProducerError, parameter, legacyParameter, parameterError, invalidParameterError };
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
@ -313,10 +323,10 @@ describe("Effect-native flow specifications", () => {
|
|||
expect(errors.parameterError.resourceType).toBe("parameter");
|
||||
expect(errors.invalidParameterError._tag).toBe("FlowParameterDecodeError");
|
||||
expect(errors.invalidParameterError.parameterName).toBe("present");
|
||||
expect(errors.legacyProducerError._tag).toBe("FlowResourceNotFoundError");
|
||||
expect(flow.parameter(presentParameter)).toBe(42);
|
||||
expect(flow.parameter("present")).toBe(42);
|
||||
expect(() => flow.parameter(invalidParameter)).toThrow("failed schema decoding");
|
||||
expect(() => flow.producer("missing-producer")).toThrow("not found");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,18 +36,20 @@ class RecordingProducer<T> implements BackendProducer<T> {
|
|||
|
||||
constructor(private readonly onSend?: (message: T, properties?: Record<string, string>) => void) {}
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
this.onSend?.(message, properties);
|
||||
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
this.onSend?.(message, properties);
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
readonly flush: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.flushCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class ScriptedConsumer<T> implements BackendConsumer<T> {
|
||||
|
|
@ -64,27 +66,33 @@ class ScriptedConsumer<T> implements BackendConsumer<T> {
|
|||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined) {
|
||||
return message;
|
||||
}
|
||||
return null;
|
||||
receive(): Effect.Effect<Message<T> | null> {
|
||||
return Effect.sync(() => {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined) {
|
||||
return message;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async acknowledge(message: Message<T>): Promise<void> {
|
||||
this.acknowledged.push(message);
|
||||
acknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.acknowledged.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async negativeAcknowledge(message: Message<T>): Promise<void> {
|
||||
this.nacked.push(message);
|
||||
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.nacked.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(): Promise<void> {}
|
||||
readonly unsubscribe: Effect.Effect<void> = Effect.void;
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class RuntimeBackend implements PubSubBackend {
|
||||
|
|
@ -100,19 +108,23 @@ class RuntimeBackend implements PubSubBackend {
|
|||
this.producer = new RecordingProducer<unknown>(onSend);
|
||||
}
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
this.producerOptions = options;
|
||||
return this.producer as BackendProducer<T>;
|
||||
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.producerOptions = options;
|
||||
return this.producer as BackendProducer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
this.consumerOptions = options;
|
||||
return this.consumer as BackendConsumer<T>;
|
||||
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.consumerOptions = options;
|
||||
return this.consumer as BackendConsumer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class ConsumerHandle {
|
||||
|
|
@ -123,31 +135,33 @@ class ConcurrentConsumerBackend implements PubSubBackend {
|
|||
readonly consumerOptions: Array<CreateConsumerOptions> = [];
|
||||
readonly consumers: Array<ConsumerHandle> = [];
|
||||
|
||||
async createProducer<T>(_options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
|
||||
return {
|
||||
send: async () => {},
|
||||
flush: async () => {},
|
||||
close: async () => {},
|
||||
};
|
||||
createProducer<T>(_options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.succeed({
|
||||
send: () => Effect.void,
|
||||
flush: Effect.void,
|
||||
close: Effect.void,
|
||||
});
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>> {
|
||||
const handle = new ConsumerHandle();
|
||||
this.consumerOptions.push(options);
|
||||
this.consumers.push(handle);
|
||||
createConsumer<T>(options: CreateConsumerOptions<T>): Effect.Effect<BackendConsumer<T>> {
|
||||
return Effect.sync(() => {
|
||||
const handle = new ConsumerHandle();
|
||||
this.consumerOptions.push(options);
|
||||
this.consumers.push(handle);
|
||||
|
||||
return {
|
||||
receive: async () => null,
|
||||
acknowledge: async () => {},
|
||||
negativeAcknowledge: async () => {},
|
||||
unsubscribe: async () => {},
|
||||
close: async () => {
|
||||
handle.closeCount += 1;
|
||||
},
|
||||
};
|
||||
return {
|
||||
receive: () => Effect.succeed(null),
|
||||
acknowledge: () => Effect.void,
|
||||
negativeAcknowledge: () => Effect.void,
|
||||
unsubscribe: Effect.void,
|
||||
close: Effect.sync(() => {
|
||||
handle.closeCount += 1;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
readonly close: Effect.Effect<void> = Effect.void;
|
||||
}
|
||||
|
||||
const flowContext: FlowContext = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Effect } from "effect";
|
||||
import { makeNatsBackend } from "../backend/nats.js";
|
||||
|
||||
const natsMock = vi.hoisted(() => {
|
||||
|
|
@ -34,6 +35,7 @@ const natsMock = vi.hoisted(() => {
|
|||
|
||||
const publish = vi.fn();
|
||||
const consumersGet = vi.fn();
|
||||
const consumersInfo = vi.fn();
|
||||
const consumersAdd = vi.fn();
|
||||
const streamsInfo = vi.fn();
|
||||
const streamsAdd = vi.fn();
|
||||
|
|
@ -50,6 +52,7 @@ const natsMock = vi.hoisted(() => {
|
|||
connect,
|
||||
consumersAdd,
|
||||
consumersGet,
|
||||
consumersInfo,
|
||||
decoder,
|
||||
drain,
|
||||
encoder,
|
||||
|
|
@ -86,6 +89,7 @@ function resetNatsMock(): void {
|
|||
|
||||
natsMock.publish.mockResolvedValue({ duplicate: false, seq: 1, stream: "tg_test" });
|
||||
natsMock.consumersGet.mockResolvedValue({ next: natsMock.next });
|
||||
natsMock.consumersInfo.mockResolvedValue({ name: "worker" });
|
||||
natsMock.consumersAdd.mockResolvedValue(undefined);
|
||||
natsMock.streamsInfo.mockResolvedValue({ config: { name: "tg_test" } });
|
||||
natsMock.streamsAdd.mockResolvedValue(undefined);
|
||||
|
|
@ -108,7 +112,7 @@ function resetNatsMock(): void {
|
|||
}),
|
||||
jetstreamManager: () =>
|
||||
Promise.resolve({
|
||||
consumers: { add: natsMock.consumersAdd },
|
||||
consumers: { add: natsMock.consumersAdd, info: natsMock.consumersInfo },
|
||||
streams: {
|
||||
add: natsMock.streamsAdd,
|
||||
info: natsMock.streamsInfo,
|
||||
|
|
@ -126,7 +130,7 @@ describe("NATS backend", () => {
|
|||
natsMock.streamsInfo.mockRejectedValueOnce(makeNatsError("404", 404));
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
|
||||
await backend.createProducer<string>({ topic: "tg.test.topic" });
|
||||
await Effect.runPromise(backend.createProducer<string>({ topic: "tg.test.topic" }));
|
||||
|
||||
expect(natsMock.streamsAdd).toHaveBeenCalledWith({
|
||||
name: "tg_test",
|
||||
|
|
@ -137,11 +141,11 @@ describe("NATS backend", () => {
|
|||
it("caches initialized streams through the Effect mutable set", async () => {
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
|
||||
await backend.createProducer<string>({ topic: "tg.test.topic" });
|
||||
await backend.createConsumer<string>({
|
||||
await Effect.runPromise(backend.createProducer<string>({ topic: "tg.test.topic" }));
|
||||
await Effect.runPromise(backend.createConsumer<string>({
|
||||
topic: "tg.test.other",
|
||||
subscription: "worker",
|
||||
});
|
||||
}));
|
||||
|
||||
expect(natsMock.streamsInfo).toHaveBeenCalledTimes(1);
|
||||
expect(natsMock.streamsInfo).toHaveBeenCalledWith("tg_test");
|
||||
|
|
@ -151,7 +155,9 @@ describe("NATS backend", () => {
|
|||
natsMock.streamsInfo.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION"));
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
|
||||
const error = await backend.createProducer<string>({ topic: "tg.test.topic" }).catch((caught: unknown) => caught);
|
||||
const error = await Effect.runPromise(
|
||||
backend.createProducer<string>({ topic: "tg.test.topic" }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "PubSubError",
|
||||
|
|
@ -161,15 +167,13 @@ describe("NATS backend", () => {
|
|||
});
|
||||
|
||||
it("creates durable consumers only when consumer lookup returns a JetStream 404", async () => {
|
||||
natsMock.consumersGet
|
||||
.mockRejectedValueOnce(makeNatsError("404", 404))
|
||||
.mockResolvedValueOnce({ next: natsMock.next });
|
||||
natsMock.consumersInfo.mockRejectedValueOnce(makeNatsError("404", 404));
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
|
||||
await backend.createConsumer<string>({
|
||||
await Effect.runPromise(backend.createConsumer<string>({
|
||||
topic: "tg.test.topic",
|
||||
subscription: "worker",
|
||||
});
|
||||
}));
|
||||
|
||||
expect(natsMock.consumersAdd).toHaveBeenCalledWith("tg_test", {
|
||||
ack_policy: "explicit",
|
||||
|
|
@ -180,13 +184,13 @@ describe("NATS backend", () => {
|
|||
});
|
||||
|
||||
it("does not create durable consumers for non-missing lookup failures", async () => {
|
||||
natsMock.consumersGet.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION"));
|
||||
natsMock.consumersInfo.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION"));
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
|
||||
const error = await backend.createConsumer<string>({
|
||||
const error = await Effect.runPromise(backend.createConsumer<string>({
|
||||
topic: "tg.test.topic",
|
||||
subscription: "worker",
|
||||
}).catch((caught: unknown) => caught);
|
||||
})).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "PubSubError",
|
||||
|
|
@ -200,9 +204,11 @@ describe("NATS backend", () => {
|
|||
throw "invalid header";
|
||||
});
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
const producer = await backend.createProducer<string>({ topic: "tg.test.topic" });
|
||||
const producer = await Effect.runPromise(backend.createProducer<string>({ topic: "tg.test.topic" }));
|
||||
|
||||
const error = await producer.send("hello", { bad: "value" }).catch((caught: unknown) => caught);
|
||||
const error = await Effect.runPromise(
|
||||
producer.send("hello", { bad: "value" }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "PubSubError",
|
||||
|
|
@ -219,19 +225,23 @@ describe("NATS backend", () => {
|
|||
throw "nak failed";
|
||||
});
|
||||
const backend = makeNatsBackend("nats://test");
|
||||
const consumer = await backend.createConsumer<string>({
|
||||
const consumer = await Effect.runPromise(backend.createConsumer<string>({
|
||||
topic: "tg.test.topic",
|
||||
subscription: "worker",
|
||||
});
|
||||
const message = await consumer.receive(1);
|
||||
}));
|
||||
const message = await Effect.runPromise(consumer.receive(1));
|
||||
|
||||
expect(message).not.toBeNull();
|
||||
if (message === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ackError = await consumer.acknowledge(message).catch((caught: unknown) => caught);
|
||||
const nakError = await consumer.negativeAcknowledge(message).catch((caught: unknown) => caught);
|
||||
const ackError = await Effect.runPromise(
|
||||
consumer.acknowledge(message),
|
||||
).catch((caught: unknown) => caught);
|
||||
const nakError = await Effect.runPromise(
|
||||
consumer.negativeAcknowledge(message),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(ackError).toMatchObject({
|
||||
_tag: "PubSubError",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
makeProducer,
|
||||
pubSubError,
|
||||
type BackendConsumer,
|
||||
type BackendProducer,
|
||||
type CreateConsumerOptions,
|
||||
|
|
@ -15,30 +17,33 @@ class ProducerBackend implements PubSubBackend {
|
|||
flushCount = 0;
|
||||
failFlush = false;
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
|
||||
this.producerTopics.push(options.topic);
|
||||
createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.producerTopics.push(options.topic);
|
||||
|
||||
return {
|
||||
send: async (message, properties) => {
|
||||
return {
|
||||
send: (message, properties) => Effect.sync(() => {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
},
|
||||
flush: async () => {
|
||||
this.flushCount += 1;
|
||||
if (this.failFlush) {
|
||||
return Promise.reject("flush failed");
|
||||
}
|
||||
},
|
||||
close: async () => {
|
||||
this.closeCount += 1;
|
||||
},
|
||||
};
|
||||
}),
|
||||
flush: Effect.suspend(() => {
|
||||
this.flushCount += 1;
|
||||
if (this.failFlush) {
|
||||
return Effect.fail(pubSubError("flush", "flush failed"));
|
||||
}
|
||||
return Effect.void;
|
||||
}),
|
||||
close: Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
createConsumer<T>(_options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>> {
|
||||
return Promise.reject("consumer not supported");
|
||||
createConsumer<T>(_options: CreateConsumerOptions<T>): Effect.Effect<BackendConsumer<T>> {
|
||||
return Effect.fail(pubSubError("create-consumer", "consumer not supported"));
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
readonly close: Effect.Effect<void> = Effect.void;
|
||||
}
|
||||
|
||||
describe("Producer", () => {
|
||||
|
|
@ -46,9 +51,9 @@ describe("Producer", () => {
|
|||
const backend = new ProducerBackend();
|
||||
const producer = makeProducer<string>(backend, "tg.test.producer");
|
||||
|
||||
await producer.start();
|
||||
await producer.send("message-1", "hello");
|
||||
await producer.stop();
|
||||
await Effect.runPromise(producer.start);
|
||||
await Effect.runPromise(producer.send("message-1", "hello"));
|
||||
await Effect.runPromise(producer.stop);
|
||||
|
||||
expect(backend.producerTopics).toEqual(["tg.test.producer"]);
|
||||
expect(backend.sent).toEqual([
|
||||
|
|
@ -56,9 +61,11 @@ describe("Producer", () => {
|
|||
]);
|
||||
expect(backend.flushCount).toBe(1);
|
||||
expect(backend.closeCount).toBe(1);
|
||||
await expect(producer.stop()).resolves.toBeUndefined();
|
||||
await expect(Effect.runPromise(producer.stop)).resolves.toBeUndefined();
|
||||
|
||||
const error = await producer.send("message-2", "late").catch((caught: unknown) => caught);
|
||||
const error = await Effect.runPromise(
|
||||
producer.send("message-2", "late"),
|
||||
).catch((caught: unknown) => caught);
|
||||
expect(error).toMatchObject({
|
||||
_tag: "MessagingLifecycleError",
|
||||
operation: "send",
|
||||
|
|
@ -70,10 +77,10 @@ describe("Producer", () => {
|
|||
const backend = new ProducerBackend();
|
||||
const producer = makeProducer<string>(backend, "tg.test.producer");
|
||||
|
||||
await producer.start();
|
||||
await Effect.runPromise(producer.start);
|
||||
backend.failFlush = true;
|
||||
|
||||
const error = await producer.stop().catch((caught: unknown) => caught);
|
||||
const error = await Effect.runPromise(producer.stop).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "MessagingDeliveryError",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
makeRequestResponse,
|
||||
type BackendConsumer,
|
||||
|
|
@ -23,18 +24,20 @@ class RecordingProducer<T> implements BackendProducer<T> {
|
|||
|
||||
constructor(private readonly onSend?: (message: T, properties?: Record<string, string>) => void) {}
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
this.onSend?.(message, properties);
|
||||
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
this.onSend?.(message, properties);
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
readonly flush: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.flushCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class WaitingConsumer<T> implements BackendConsumer<T> {
|
||||
|
|
@ -55,32 +58,38 @@ class WaitingConsumer<T> implements BackendConsumer<T> {
|
|||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || this.closed) return message ?? null;
|
||||
receive(): Effect.Effect<Message<T> | null> {
|
||||
return Effect.promise(() => {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || this.closed) return Promise.resolve(message ?? null);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async acknowledge(message: Message<T>): Promise<void> {
|
||||
this.acknowledged.push(message);
|
||||
acknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.acknowledged.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async negativeAcknowledge(message: Message<T>): Promise<void> {
|
||||
this.nacked.push(message);
|
||||
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.nacked.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(): Promise<void> {}
|
||||
readonly unsubscribe: Effect.Effect<void> = Effect.void;
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closed = true;
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
waiter(null);
|
||||
}
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class RuntimeBackend implements PubSubBackend {
|
||||
|
|
@ -96,19 +105,23 @@ class RuntimeBackend implements PubSubBackend {
|
|||
this.producer = new RecordingProducer<unknown>(onSend);
|
||||
}
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
this.producerOptions = options;
|
||||
return this.producer as BackendProducer<T>;
|
||||
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.producerOptions = options;
|
||||
return this.producer as BackendProducer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
this.consumerOptions = options;
|
||||
return this.consumer as BackendConsumer<T>;
|
||||
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.consumerOptions = options;
|
||||
return this.consumer as BackendConsumer<T>;
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("RequestResponse compatibility facade", () => {
|
||||
|
|
@ -127,9 +140,9 @@ describe("RequestResponse compatibility facade", () => {
|
|||
subscription: "sub",
|
||||
});
|
||||
|
||||
await requestor.start();
|
||||
const response = await requestor.request("request", { timeoutMs: 250 });
|
||||
await requestor.stop();
|
||||
await Effect.runPromise(requestor.start);
|
||||
const response = await Effect.runPromise(requestor.request("request", { timeoutMs: 250 }));
|
||||
await Effect.runPromise(requestor.stop);
|
||||
|
||||
expect(response).toBe("response");
|
||||
expect(backend.producerOptions).toEqual({ topic: "request-topic" });
|
||||
|
|
@ -150,9 +163,11 @@ describe("RequestResponse compatibility facade", () => {
|
|||
subscription: "sub",
|
||||
});
|
||||
|
||||
await requestor.start();
|
||||
const error = await requestor.request("request", { timeoutMs: 5 }).catch((caught: unknown) => caught);
|
||||
await requestor.stop();
|
||||
await Effect.runPromise(requestor.start);
|
||||
const error = await Effect.runPromise(
|
||||
requestor.request("request", { timeoutMs: 5 }),
|
||||
).catch((caught: unknown) => caught);
|
||||
await Effect.runPromise(requestor.stop);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "MessagingTimeoutError",
|
||||
|
|
@ -171,7 +186,9 @@ describe("RequestResponse compatibility facade", () => {
|
|||
subscription: "sub",
|
||||
});
|
||||
|
||||
const error = await requestor.request("request").catch((caught: unknown) => caught);
|
||||
const error = await Effect.runPromise(
|
||||
requestor.request("request"),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "MessagingLifecycleError",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as S from "effect/Schema";
|
|||
import {
|
||||
PubSub,
|
||||
makeAsyncProcessor,
|
||||
pubSubError,
|
||||
runProcessorScoped,
|
||||
type BackendConsumer,
|
||||
type BackendProducer,
|
||||
|
|
@ -24,39 +25,45 @@ class FakeProducer<T> implements BackendProducer<T> {
|
|||
closeCount = 0;
|
||||
flushCount = 0;
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(
|
||||
properties === undefined
|
||||
? { message }
|
||||
: { message, properties },
|
||||
);
|
||||
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
|
||||
return Effect.sync(() => {
|
||||
this.sent.push(
|
||||
properties === undefined
|
||||
? { message }
|
||||
: { message, properties },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
readonly flush: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.flushCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class FakeConsumer<T> implements BackendConsumer<T> {
|
||||
closeCount = 0;
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
return null;
|
||||
receive(): Effect.Effect<Message<T> | null> {
|
||||
return Effect.succeed(null);
|
||||
}
|
||||
|
||||
async acknowledge(): Promise<void> {}
|
||||
acknowledge(): Effect.Effect<void> {
|
||||
return Effect.void;
|
||||
}
|
||||
|
||||
async negativeAcknowledge(): Promise<void> {}
|
||||
negativeAcknowledge(): Effect.Effect<void> {
|
||||
return Effect.void;
|
||||
}
|
||||
|
||||
async unsubscribe(): Promise<void> {}
|
||||
readonly unsubscribe: Effect.Effect<void> = Effect.void;
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class FakePubSubBackend implements PubSubBackend {
|
||||
|
|
@ -64,24 +71,30 @@ class FakePubSubBackend implements PubSubBackend {
|
|||
producerOptions: CreateProducerOptions | null = null;
|
||||
consumerOptions: CreateConsumerOptions | null = null;
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
this.producerOptions = options;
|
||||
return new FakeProducer<T>();
|
||||
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.producerOptions = options;
|
||||
return new FakeProducer<T>();
|
||||
});
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
this.consumerOptions = options;
|
||||
return new FakeConsumer<T>();
|
||||
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
|
||||
return Effect.sync(() => {
|
||||
this.consumerOptions = options;
|
||||
return new FakeConsumer<T>();
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
readonly close: Effect.Effect<void> = Effect.sync(() => {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class FailingProducerBackend extends FakePubSubBackend {
|
||||
override async createProducer<T>(): Promise<BackendProducer<T>> {
|
||||
throw RuntimeServicesTestError.make({ message: "producer unavailable" });
|
||||
override createProducer<T>(): Effect.Effect<BackendProducer<T>> {
|
||||
return Effect.fail(
|
||||
pubSubError("createProducer:tg.test.failure", RuntimeServicesTestError.make({ message: "producer unavailable" })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,23 +103,19 @@ const makeRecordingProcessor = (
|
|||
events: Array<string>,
|
||||
) => {
|
||||
const processor = makeAsyncProcessor(config, {
|
||||
run: async (runtime) => {
|
||||
run: (runtime) => Effect.sync(() => {
|
||||
events.push(`run:${runtime.config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`);
|
||||
},
|
||||
}),
|
||||
});
|
||||
const stop = processor.stop;
|
||||
processor.stop = async () => {
|
||||
processor.onShutdown(() => Effect.sync(() => {
|
||||
events.push("stop");
|
||||
await stop();
|
||||
};
|
||||
}));
|
||||
return processor;
|
||||
};
|
||||
|
||||
const makeFailingProcessor = (config: ProcessorConfig) =>
|
||||
makeAsyncProcessor(config, {
|
||||
run: async () => {
|
||||
throw RuntimeServicesTestError.make({ message: "processor failed" });
|
||||
},
|
||||
run: () => Effect.fail(RuntimeServicesTestError.make({ message: "processor failed" })),
|
||||
});
|
||||
|
||||
const makeNativeRecordingProcessor = (
|
||||
|
|
@ -122,8 +131,9 @@ const makeNativeRecordingProcessor = (
|
|||
}),
|
||||
});
|
||||
processor.onShutdown(() => {
|
||||
events.push("native-stop");
|
||||
return Promise.resolve();
|
||||
return Effect.sync(() => {
|
||||
events.push("native-stop");
|
||||
});
|
||||
});
|
||||
return processor;
|
||||
};
|
||||
|
|
@ -138,7 +148,7 @@ describe("Effect runtime services", () => {
|
|||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
const producer = yield* pubsub.createProducer<string>({ topic: "tg.test.topic" });
|
||||
yield* Effect.promise(() => producer.send("hello", { id: "1" }));
|
||||
yield* producer.send("hello", { id: "1" });
|
||||
|
||||
expect(backend.producerOptions).toEqual({ topic: "tg.test.topic" });
|
||||
expect(pubsub.backend).toBe(backend);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import type {
|
|||
CreateConsumerOptions,
|
||||
Message,
|
||||
} from "./types.js";
|
||||
import { pubSubError } from "../errors.js";
|
||||
import { pubSubError, type PubSubError } from "../errors.js";
|
||||
|
||||
const sc = StringCodec();
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ function makeNatsProducer<T>(
|
|||
): BackendProducer<T> {
|
||||
const makePublishOptions = (
|
||||
properties: Record<string, string> | undefined,
|
||||
): Effect.Effect<Partial<JetStreamPublishOptions>, ReturnType<typeof pubSubError>> => {
|
||||
): Effect.Effect<Partial<JetStreamPublishOptions>, PubSubError> => {
|
||||
if (properties === undefined || Object.keys(properties).length === 0) {
|
||||
return Effect.succeed({});
|
||||
}
|
||||
|
|
@ -131,35 +131,32 @@ function makeNatsProducer<T>(
|
|||
};
|
||||
|
||||
return {
|
||||
send: (message, properties) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const encoded = schema !== undefined
|
||||
? yield* S.encodeUnknownEffect(schema)(message).pipe(
|
||||
Effect.mapError((error) => pubSubError(`encode:${subject}`, error)),
|
||||
)
|
||||
: message;
|
||||
const json = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(encoded).pipe(
|
||||
Effect.mapError((error) => pubSubError(`encode-json:${subject}`, error)),
|
||||
);
|
||||
const data = sc.encode(json);
|
||||
const opts = yield* makePublishOptions(properties);
|
||||
send: Effect.fn(`NatsProducer.send:${subject}`)(function*(message: T, properties?: Record<string, string>) {
|
||||
const encoded = schema !== undefined
|
||||
? yield* S.encodeUnknownEffect(schema)(message).pipe(
|
||||
Effect.mapError((error) => pubSubError(`encode:${subject}`, error)),
|
||||
)
|
||||
: message;
|
||||
const json = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(encoded).pipe(
|
||||
Effect.mapError((error) => pubSubError(`encode-json:${subject}`, error)),
|
||||
);
|
||||
const data = sc.encode(json);
|
||||
const opts = yield* makePublishOptions(properties);
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => js.publish(subject, data, opts),
|
||||
catch: (error) => pubSubError(`publish:${subject}`, error),
|
||||
});
|
||||
}),
|
||||
),
|
||||
yield* Effect.tryPromise({
|
||||
try: () => js.publish(subject, data, opts),
|
||||
catch: (error) => pubSubError(`publish:${subject}`, error),
|
||||
});
|
||||
}),
|
||||
// NATS publishes are flushed on the connection level.
|
||||
flush: () => Promise.resolve(),
|
||||
flush: Effect.void,
|
||||
// No per-producer cleanup needed for NATS.
|
||||
close: () => Promise.resolve(),
|
||||
close: Effect.void,
|
||||
};
|
||||
}
|
||||
|
||||
interface InitializableBackendConsumer<T> extends BackendConsumer<T> {
|
||||
readonly init: () => Promise<void>;
|
||||
readonly init: Effect.Effect<void, PubSubError>;
|
||||
}
|
||||
|
||||
function makeNatsConsumer<T>(
|
||||
|
|
@ -173,115 +170,111 @@ function makeNatsConsumer<T>(
|
|||
): InitializableBackendConsumer<T> {
|
||||
let consumer: NatsJsConsumer | null = null;
|
||||
|
||||
const isReceiveTimeoutError = (error: unknown): boolean => {
|
||||
const code = P.isObject(error) ? (error as { readonly code?: unknown }).code : undefined;
|
||||
return code === 408 || code === "408" || code === ErrorCode.Timeout;
|
||||
};
|
||||
|
||||
return {
|
||||
init: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const existing = yield* Effect.tryPromise({
|
||||
try: () => js.consumers.get(streamName, subscription),
|
||||
catch: (error) => natsLookupError(`get-consumer:${streamName}:${subscription}`, error),
|
||||
}).pipe(
|
||||
Effect.catchIf(
|
||||
isMissingLookupError,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const deliverPolicy =
|
||||
initialPosition === "earliest"
|
||||
? DeliverPolicy.All
|
||||
: DeliverPolicy.New;
|
||||
init: Effect.gen(function* () {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => jsm.consumers.info(streamName, subscription),
|
||||
catch: (error) => natsLookupError(`consumer-info:${streamName}:${subscription}`, error),
|
||||
}).pipe(
|
||||
Effect.catchIf(
|
||||
isMissingLookupError,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const deliverPolicy =
|
||||
initialPosition === "earliest"
|
||||
? DeliverPolicy.All
|
||||
: DeliverPolicy.New;
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
jsm.consumers.add(streamName, {
|
||||
durable_name: subscription,
|
||||
ack_policy: AckPolicy.Explicit,
|
||||
deliver_policy: deliverPolicy,
|
||||
filter_subject: subject,
|
||||
}),
|
||||
catch: (error) => pubSubError(`add-consumer:${streamName}:${subscription}`, error),
|
||||
});
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
jsm.consumers.add(streamName, {
|
||||
durable_name: subscription,
|
||||
ack_policy: AckPolicy.Explicit,
|
||||
deliver_policy: deliverPolicy,
|
||||
filter_subject: subject,
|
||||
}),
|
||||
catch: (error) => pubSubError(`add-consumer:${streamName}:${subscription}`, error),
|
||||
});
|
||||
}),
|
||||
(error) => Effect.fail(pubSubError(error.operation, error.cause)),
|
||||
),
|
||||
);
|
||||
consumer = yield* Effect.tryPromise({
|
||||
try: () => js.consumers.get(streamName, subscription),
|
||||
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
|
||||
});
|
||||
}),
|
||||
receive: Effect.fn(`NatsConsumer.receive:${subject}`)(function*(timeoutMs = 2000) {
|
||||
const current = consumer;
|
||||
if (current === null) {
|
||||
return yield* pubSubError("receive", "Consumer not initialized");
|
||||
}
|
||||
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => js.consumers.get(streamName, subscription),
|
||||
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
|
||||
});
|
||||
}),
|
||||
(error) => Effect.fail(pubSubError(error.operation, error.cause)),
|
||||
),
|
||||
const msg = yield* Effect.tryPromise({
|
||||
try: () => current.next({ expires: timeoutMs }),
|
||||
catch: (error) =>
|
||||
isReceiveTimeoutError(error)
|
||||
? pubSubError(`receive-timeout:${subject}`, error)
|
||||
: pubSubError(`receive:${subject}`, error),
|
||||
}).pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error.operation === `receive-timeout:${subject}`,
|
||||
() => Effect.succeed(null),
|
||||
),
|
||||
);
|
||||
if (msg === null) return null;
|
||||
|
||||
const parsed = yield* S.decodeUnknownEffect(S.UnknownFromJsonString)(sc.decode(msg.data)).pipe(
|
||||
Effect.mapError((error) => pubSubError(`decode-json:${subject}`, error)),
|
||||
);
|
||||
const decoded = schema !== undefined
|
||||
? yield* S.decodeUnknownEffect(schema)(parsed).pipe(
|
||||
Effect.mapError((error) => pubSubError(`decode-schema:${subject}`, error)),
|
||||
)
|
||||
: yield* S.decodeUnknownEffect(S.Any)(parsed).pipe(
|
||||
Effect.mapError((error) => pubSubError(`decode-any:${subject}`, error)),
|
||||
);
|
||||
consumer = existing;
|
||||
}),
|
||||
),
|
||||
receive: (timeoutMs = 2000) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const current = consumer;
|
||||
if (current === null) {
|
||||
return yield* pubSubError("receive", "Consumer not initialized");
|
||||
}
|
||||
|
||||
// Pull a single message with a timeout using the pull-based API.
|
||||
// consumer.next() returns a JsMsg or null when the timeout expires.
|
||||
const msg = yield* Effect.tryPromise({
|
||||
try: () => current.next({ expires: timeoutMs }),
|
||||
catch: (error) => pubSubError(`receive:${subject}`, error),
|
||||
});
|
||||
if (msg === null) return null;
|
||||
|
||||
const parsed = yield* S.decodeUnknownEffect(S.UnknownFromJsonString)(sc.decode(msg.data)).pipe(
|
||||
Effect.mapError((error) => pubSubError(`decode-json:${subject}`, error)),
|
||||
);
|
||||
const decoded = schema !== undefined
|
||||
? yield* S.decodeUnknownEffect(schema)(parsed).pipe(
|
||||
Effect.mapError((error) => pubSubError(`decode-schema:${subject}`, error)),
|
||||
)
|
||||
: yield* S.decodeUnknownEffect(S.Any)(parsed).pipe(
|
||||
Effect.mapError((error) => pubSubError(`decode-any:${subject}`, error)),
|
||||
);
|
||||
return makeNatsMessage(msg, decoded);
|
||||
}),
|
||||
),
|
||||
acknowledge: (message) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
if (!isNatsMessage(message)) {
|
||||
return yield* pubSubError(`acknowledge:${subject}`, "Message was not produced by NATS backend");
|
||||
}
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
message._jsMsg.ack();
|
||||
},
|
||||
catch: (error) => pubSubError(`acknowledge:${subject}`, error),
|
||||
});
|
||||
}),
|
||||
),
|
||||
negativeAcknowledge: (message) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
if (!isNatsMessage(message)) {
|
||||
return yield* pubSubError(
|
||||
`negative-acknowledge:${subject}`,
|
||||
"Message was not produced by NATS backend",
|
||||
);
|
||||
}
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
message._jsMsg.nak();
|
||||
},
|
||||
catch: (error) => pubSubError(`negative-acknowledge:${subject}`, error),
|
||||
});
|
||||
}),
|
||||
),
|
||||
unsubscribe: () => {
|
||||
// The pull-based consumer does not have a persistent subscription to drain.
|
||||
// Clearing the reference is sufficient; the durable consumer persists server-side.
|
||||
return makeNatsMessage(msg, decoded);
|
||||
}),
|
||||
acknowledge: Effect.fn(`NatsConsumer.acknowledge:${subject}`)(function*(message: Message<T>) {
|
||||
if (!isNatsMessage(message)) {
|
||||
return yield* pubSubError(
|
||||
`acknowledge:${subject}`,
|
||||
"Message was not produced by NATS backend",
|
||||
);
|
||||
}
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
message._jsMsg.ack();
|
||||
},
|
||||
catch: (error) => pubSubError(`acknowledge:${subject}`, error),
|
||||
});
|
||||
}),
|
||||
negativeAcknowledge: Effect.fn(`NatsConsumer.negativeAcknowledge:${subject}`)(function*(message: Message<T>) {
|
||||
if (!isNatsMessage(message)) {
|
||||
return yield* pubSubError(
|
||||
`negative-acknowledge:${subject}`,
|
||||
"Message was not produced by NATS backend",
|
||||
);
|
||||
}
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
message._jsMsg.nak();
|
||||
},
|
||||
catch: (error) => pubSubError(`negative-acknowledge:${subject}`, error),
|
||||
});
|
||||
}),
|
||||
unsubscribe: Effect.sync(() => {
|
||||
consumer = null;
|
||||
return Promise.resolve();
|
||||
},
|
||||
close: () => {
|
||||
}),
|
||||
close: Effect.sync(() => {
|
||||
consumer = null;
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +312,9 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
|
|||
const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`;
|
||||
|
||||
const manager = jsm;
|
||||
if (manager === null) return yield* pubSubError("ensure-stream", "NATS backend not connected");
|
||||
if (manager === null) {
|
||||
return yield* pubSubError("ensure-stream", "NATS backend not connected");
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => manager.streams.info(streamName),
|
||||
|
|
@ -344,56 +339,48 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
|
|||
});
|
||||
|
||||
return {
|
||||
createProducer: <T>(options: CreateProducerOptions<T>) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* ensureConnected();
|
||||
yield* ensureStream(options.topic);
|
||||
const client = js;
|
||||
if (client === null) return yield* pubSubError("create-producer", "NATS backend not connected");
|
||||
return makeNatsProducer<T>(client, options.topic, options.schema);
|
||||
}),
|
||||
),
|
||||
createConsumer: <T>(options: CreateConsumerOptions<T>) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* ensureConnected();
|
||||
const streamName = yield* ensureStream(options.topic);
|
||||
const client = js;
|
||||
const manager = jsm;
|
||||
if (client === null || manager === null) {
|
||||
return yield* pubSubError("create-consumer", "NATS backend not connected");
|
||||
}
|
||||
const consumer = makeNatsConsumer<T>(
|
||||
client,
|
||||
manager,
|
||||
options.topic,
|
||||
options.subscription,
|
||||
options.initialPosition ?? "latest",
|
||||
streamName,
|
||||
options.schema,
|
||||
);
|
||||
yield* Effect.tryPromise({
|
||||
try: () => consumer.init(),
|
||||
catch: (error) => pubSubError(`init-consumer:${options.topic}`, error),
|
||||
});
|
||||
return consumer;
|
||||
}),
|
||||
),
|
||||
close: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const conn = connection;
|
||||
if (conn !== null) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => conn.drain(),
|
||||
catch: (error) => pubSubError("close", error),
|
||||
});
|
||||
connection = null;
|
||||
js = null;
|
||||
jsm = null;
|
||||
}
|
||||
}),
|
||||
),
|
||||
createProducer: Effect.fn("NatsBackend.createProducer")(function*<T>(options: CreateProducerOptions<T>) {
|
||||
yield* ensureConnected();
|
||||
yield* ensureStream(options.topic);
|
||||
const client = js;
|
||||
if (client === null) {
|
||||
return yield* pubSubError("create-producer", "NATS backend not connected");
|
||||
}
|
||||
return makeNatsProducer<T>(client, options.topic, options.schema);
|
||||
}),
|
||||
createConsumer: Effect.fn("NatsBackend.createConsumer")(function*<T>(options: CreateConsumerOptions<T>) {
|
||||
yield* ensureConnected();
|
||||
const streamName = yield* ensureStream(options.topic);
|
||||
const client = js;
|
||||
const manager = jsm;
|
||||
if (client === null || manager === null) {
|
||||
return yield* pubSubError("create-consumer", "NATS backend not connected");
|
||||
}
|
||||
const consumer = makeNatsConsumer<T>(
|
||||
client,
|
||||
manager,
|
||||
options.topic,
|
||||
options.subscription,
|
||||
options.initialPosition ?? "latest",
|
||||
streamName,
|
||||
options.schema,
|
||||
);
|
||||
yield* consumer.init.pipe(
|
||||
Effect.mapError((error) => pubSubError(`init-consumer:${options.topic}`, error)),
|
||||
);
|
||||
return consumer;
|
||||
}),
|
||||
close: Effect.gen(function* () {
|
||||
const conn = connection;
|
||||
if (conn !== null) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => conn.drain(),
|
||||
catch: (error) => pubSubError("close", error),
|
||||
});
|
||||
connection = null;
|
||||
js = null;
|
||||
jsm = null;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Effect-native pub/sub capability for runtime composition.
|
||||
*
|
||||
* The existing Promise-based backend protocol stays available as the
|
||||
* compatibility bridge while service code moves to `Context.Service`/Layers.
|
||||
* The backend protocol is Effect-native; this service provides the
|
||||
* Context.Service/Layer boundary used by runtime composition.
|
||||
*/
|
||||
|
||||
import { Config, Context, Effect, Layer } from "effect";
|
||||
|
|
@ -15,17 +15,17 @@ import type {
|
|||
PubSubBackend,
|
||||
} from "./types.js";
|
||||
import { makeNatsBackend } from "./nats.js";
|
||||
import { pubSubError } from "../errors.js";
|
||||
import type { PubSubError } from "../errors.js";
|
||||
|
||||
export interface PubSubService {
|
||||
readonly backend: PubSubBackend;
|
||||
readonly createProducer: <T>(
|
||||
options: CreateProducerOptions<T>,
|
||||
) => Effect.Effect<BackendProducer<T>, ReturnType<typeof pubSubError>>;
|
||||
) => Effect.Effect<BackendProducer<T>, PubSubError>;
|
||||
readonly createConsumer: <T>(
|
||||
options: CreateConsumerOptions<T>,
|
||||
) => Effect.Effect<BackendConsumer<T>, ReturnType<typeof pubSubError>>;
|
||||
readonly close: Effect.Effect<void, ReturnType<typeof pubSubError>>;
|
||||
) => Effect.Effect<BackendConsumer<T>, PubSubError>;
|
||||
readonly close: Effect.Effect<void, PubSubError>;
|
||||
}
|
||||
|
||||
export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgraph/base/backend/pubsub") {
|
||||
|
|
@ -41,20 +41,9 @@ export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgrap
|
|||
export function makePubSubService(backend: PubSubBackend): PubSubService {
|
||||
return {
|
||||
backend,
|
||||
createProducer: <T>(options: CreateProducerOptions<T>) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.createProducer<T>(options),
|
||||
catch: (error) => pubSubError(`createProducer:${options.topic}`, error),
|
||||
}),
|
||||
createConsumer: <T>(options: CreateConsumerOptions<T>) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.createConsumer<T>(options),
|
||||
catch: (error) => pubSubError(`createConsumer:${options.topic}`, error),
|
||||
}),
|
||||
close: Effect.tryPromise({
|
||||
try: () => backend.close(),
|
||||
catch: (error) => pubSubError("close", error),
|
||||
}),
|
||||
createProducer: <T>(options: CreateProducerOptions<T>) => backend.createProducer<T>(options),
|
||||
createConsumer: <T>(options: CreateConsumerOptions<T>) => backend.createConsumer<T>(options),
|
||||
close: backend.close,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
* (NATS, Pulsar, Redis Streams) implements these interfaces.
|
||||
*/
|
||||
|
||||
import type { Effect } from "effect";
|
||||
import type * as S from "effect/Schema";
|
||||
import type { PubSubError } from "../errors.js";
|
||||
|
||||
export interface Message<T = unknown> {
|
||||
value(): T;
|
||||
|
|
@ -13,17 +15,17 @@ export interface Message<T = unknown> {
|
|||
}
|
||||
|
||||
export interface BackendProducer<T = unknown> {
|
||||
send(message: T, properties?: Record<string, string>): Promise<void>;
|
||||
flush(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
send(message: T, properties?: Record<string, string>): Effect.Effect<void, PubSubError>;
|
||||
flush: Effect.Effect<void, PubSubError>;
|
||||
close: Effect.Effect<void, PubSubError>;
|
||||
}
|
||||
|
||||
export interface BackendConsumer<T = unknown> {
|
||||
receive(timeoutMs?: number): Promise<Message<T> | null>;
|
||||
acknowledge(message: Message<T>): Promise<void>;
|
||||
negativeAcknowledge(message: Message<T>): Promise<void>;
|
||||
unsubscribe(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
receive(timeoutMs?: number): Effect.Effect<Message<T> | null, PubSubError>;
|
||||
acknowledge(message: Message<T>): Effect.Effect<void, PubSubError>;
|
||||
negativeAcknowledge(message: Message<T>): Effect.Effect<void, PubSubError>;
|
||||
unsubscribe: Effect.Effect<void, PubSubError>;
|
||||
close: Effect.Effect<void, PubSubError>;
|
||||
}
|
||||
|
||||
export type ConsumerType = "shared" | "exclusive" | "failover";
|
||||
|
|
@ -43,7 +45,7 @@ export interface CreateConsumerOptions<T = unknown> {
|
|||
}
|
||||
|
||||
export interface PubSubBackend {
|
||||
createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>>;
|
||||
createConsumer<T>(options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>>;
|
||||
close(): Promise<void>;
|
||||
createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>, PubSubError>;
|
||||
createConsumer<T>(options: CreateConsumerOptions<T>): Effect.Effect<BackendConsumer<T>, PubSubError>;
|
||||
close: Effect.Effect<void, PubSubError>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export class MessagingTimeoutError extends S.TaggedErrorClass<MessagingTimeoutEr
|
|||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
timeoutMs: S.Number,
|
||||
timeoutMs: S.Finite,
|
||||
},
|
||||
) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,18 +12,22 @@ import {
|
|||
TooManyRequestsError,
|
||||
messagingHandlerError,
|
||||
messagingLifecycleError,
|
||||
type MessagingLifecycleError,
|
||||
} from "../errors.js";
|
||||
import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
|
||||
import { Config as EffectConfig, Effect, Exit, Scope } from "effect";
|
||||
import * as P from "effect/Predicate";
|
||||
import * as S from "effect/Schema";
|
||||
import { loadMessagingRuntimeConfig } from "../runtime/index.ts";
|
||||
import { makeEffectConsumerFromPubSub, type EffectConsumer } from "./runtime.js";
|
||||
import {
|
||||
makeEffectConsumerFromPubSub,
|
||||
type EffectConsumer,
|
||||
} from "./runtime.js";
|
||||
|
||||
export type MessageHandler<T> = (
|
||||
message: T,
|
||||
properties: Record<string, string>,
|
||||
flow: FlowContext,
|
||||
) => Promise<void>;
|
||||
) => Effect.Effect<void, TooManyRequestsError | MessagingHandlerError>;
|
||||
|
||||
export interface FlowContext<Requirements = never> {
|
||||
id: string;
|
||||
|
|
@ -47,8 +51,10 @@ declare const ConsumerMessageType: unique symbol;
|
|||
|
||||
export interface Consumer<T> {
|
||||
readonly [ConsumerMessageType]?: (_: T) => T;
|
||||
readonly start: (flow: FlowContext) => Promise<void>;
|
||||
readonly stop: () => Promise<void>;
|
||||
readonly start: (
|
||||
flow: FlowContext,
|
||||
) => Effect.Effect<void, MessagingLifecycleError | EffectConfig.ConfigError>;
|
||||
readonly stop: Effect.Effect<void, MessagingLifecycleError>;
|
||||
}
|
||||
|
||||
interface ConsumerRuntime {
|
||||
|
|
@ -56,8 +62,6 @@ interface ConsumerRuntime {
|
|||
readonly consumer: EffectConsumer;
|
||||
}
|
||||
|
||||
const consumerRuntime = ManagedRuntime.make(Layer.empty);
|
||||
|
||||
export function makeConsumer<T>(options: ConsumerOptions<T>): Consumer<T> {
|
||||
let runtime: ConsumerRuntime | null = null;
|
||||
const isTooManyRequestsError = S.is(TooManyRequestsError);
|
||||
|
|
@ -67,62 +71,58 @@ export function makeConsumer<T>(options: ConsumerOptions<T>): Consumer<T> {
|
|||
properties: Record<string, string>,
|
||||
flow: FlowContext,
|
||||
): Effect.Effect<void, TooManyRequestsError | MessagingHandlerError> =>
|
||||
Effect.tryPromise({
|
||||
try: () => options.handler(message, properties, flow),
|
||||
catch: (error) =>
|
||||
options.handler(message, properties, flow).pipe(
|
||||
Effect.mapError((error) =>
|
||||
isTooManyRequestsError(error)
|
||||
? error
|
||||
: messagingHandlerError(options.topic, options.subscription, error),
|
||||
});
|
||||
: messagingHandlerError(options.topic, options.subscription, error)
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
start: (flow) =>
|
||||
P.isNotNull(runtime)
|
||||
? Promise.resolve()
|
||||
: consumerRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make();
|
||||
const startConsumer = Effect.gen(function* () {
|
||||
const config = yield* loadMessagingRuntimeConfig();
|
||||
const consumer = yield* makeEffectConsumerFromPubSub<T, TooManyRequestsError | MessagingHandlerError, never>(
|
||||
PubSub.fromBackend(options.pubsub),
|
||||
config,
|
||||
{
|
||||
topic: options.topic,
|
||||
subscription: options.subscription,
|
||||
handler: runHandler,
|
||||
...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
|
||||
initialPosition: options.initialPosition ?? "latest",
|
||||
...(options.rateLimitRetryMs === undefined ? {} : { rateLimitRetryMs: options.rateLimitRetryMs }),
|
||||
...(options.rateLimitTimeoutMs === undefined
|
||||
? {}
|
||||
: { rateLimitTimeoutMs: options.rateLimitTimeoutMs }),
|
||||
},
|
||||
flow,
|
||||
).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.mapError((error) =>
|
||||
messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error)
|
||||
),
|
||||
);
|
||||
runtime = { scope, consumer };
|
||||
});
|
||||
|
||||
yield* startConsumer.pipe(
|
||||
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
||||
? Effect.void
|
||||
: Effect.gen(function* () {
|
||||
const scope = yield* Scope.make();
|
||||
const startConsumer = Effect.gen(function* () {
|
||||
const config = yield* loadMessagingRuntimeConfig();
|
||||
const consumer = yield* makeEffectConsumerFromPubSub<T, TooManyRequestsError | MessagingHandlerError, never>(
|
||||
PubSub.fromBackend(options.pubsub),
|
||||
config,
|
||||
{
|
||||
topic: options.topic,
|
||||
subscription: options.subscription,
|
||||
handler: runHandler,
|
||||
...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
|
||||
initialPosition: options.initialPosition ?? "latest",
|
||||
...(options.rateLimitRetryMs === undefined ? {} : { rateLimitRetryMs: options.rateLimitRetryMs }),
|
||||
...(options.rateLimitTimeoutMs === undefined
|
||||
? {}
|
||||
: { rateLimitTimeoutMs: options.rateLimitTimeoutMs }),
|
||||
},
|
||||
flow,
|
||||
).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.mapError((error) =>
|
||||
messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error)
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
stop: () => {
|
||||
runtime = { scope, consumer };
|
||||
});
|
||||
|
||||
yield* startConsumer.pipe(
|
||||
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
||||
);
|
||||
}),
|
||||
stop: Effect.suspend(() => {
|
||||
const current = runtime;
|
||||
runtime = null;
|
||||
return current === null
|
||||
? Promise.resolve()
|
||||
: consumerRuntime.runPromise(
|
||||
current.consumer.stop.pipe(
|
||||
Effect.ensuring(Scope.close(current.scope, Exit.void)),
|
||||
),
|
||||
? Effect.void
|
||||
: current.consumer.stop.pipe(
|
||||
Effect.ensuring(Scope.close(current.scope, Exit.void)),
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,16 @@ import type { ProducerMetrics } from "../metrics/index.ts";
|
|||
import { Effect, Exit, Scope } from "effect";
|
||||
import { PubSub } from "../backend/pubsub.js";
|
||||
import { makeEffectProducerFromPubSub, type EffectProducer } from "./runtime.js";
|
||||
import { messagingLifecycleError } from "../errors.js";
|
||||
import {
|
||||
messagingLifecycleError,
|
||||
type MessagingDeliveryError,
|
||||
type MessagingLifecycleError,
|
||||
} from "../errors.js";
|
||||
|
||||
export interface Producer<T> {
|
||||
readonly start: () => Promise<void>;
|
||||
readonly send: (id: string, message: T) => Promise<void>;
|
||||
readonly stop: () => Promise<void>;
|
||||
readonly start: Effect.Effect<void, MessagingLifecycleError>;
|
||||
readonly send: (id: string, message: T) => Effect.Effect<void, MessagingDeliveryError | MessagingLifecycleError>;
|
||||
readonly stop: Effect.Effect<void, MessagingDeliveryError>;
|
||||
}
|
||||
|
||||
interface ProducerRuntime<T> {
|
||||
|
|
@ -29,49 +33,46 @@ export function makeProducer<T>(
|
|||
): Producer<T> {
|
||||
let runtime: ProducerRuntime<T> | null = null;
|
||||
|
||||
const start = Effect.fn(`Producer.start:${topic}`)(function* () {
|
||||
if (runtime !== null) return;
|
||||
|
||||
const scope = yield* Scope.make();
|
||||
const startProducer = Effect.gen(function* () {
|
||||
const producer = yield* makeEffectProducerFromPubSub<T>(
|
||||
PubSub.fromBackend(pubsub),
|
||||
{
|
||||
topic,
|
||||
...(metrics === undefined ? {} : { metrics }),
|
||||
},
|
||||
).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.mapError((error) => messagingLifecycleError(topic, "create-producer", error)),
|
||||
);
|
||||
|
||||
runtime = { scope, producer };
|
||||
});
|
||||
|
||||
yield* startProducer.pipe(
|
||||
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
start: () =>
|
||||
runtime !== null
|
||||
? Promise.resolve()
|
||||
: Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make();
|
||||
const startProducer = Effect.gen(function* () {
|
||||
const producer = yield* makeEffectProducerFromPubSub<T>(
|
||||
PubSub.fromBackend(pubsub),
|
||||
{
|
||||
topic,
|
||||
...(metrics === undefined ? {} : { metrics }),
|
||||
},
|
||||
).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.mapError((error) => messagingLifecycleError(topic, "create-producer", error)),
|
||||
);
|
||||
|
||||
runtime = { scope, producer };
|
||||
});
|
||||
|
||||
yield* startProducer.pipe(
|
||||
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
||||
);
|
||||
}),
|
||||
),
|
||||
start: start(),
|
||||
send: (id, message) => {
|
||||
const current = runtime;
|
||||
return current === null
|
||||
? Effect.runPromise(Effect.fail(messagingLifecycleError(topic, "send", "Producer not started")))
|
||||
: Effect.runPromise(current.producer.send(id, message));
|
||||
? Effect.fail(messagingLifecycleError(topic, "send", "Producer not started"))
|
||||
: current.producer.send(id, message);
|
||||
},
|
||||
stop: () => {
|
||||
stop: Effect.suspend(() => {
|
||||
const current = runtime;
|
||||
runtime = null;
|
||||
return current === null
|
||||
? Promise.resolve()
|
||||
: Effect.runPromise(
|
||||
current.producer.flush.pipe(
|
||||
Effect.ensuring(Scope.close(current.scope, Exit.void)),
|
||||
),
|
||||
? Effect.void
|
||||
: current.producer.flush.pipe(
|
||||
Effect.ensuring(Scope.close(current.scope, Exit.void)),
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,22 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/request_response_spec.py
|
||||
*/
|
||||
|
||||
import { Effect, Exit, Scope } from "effect";
|
||||
import { Config as EffectConfig, Effect, Exit, Scope } from "effect";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import { PubSub } from "../backend/pubsub.js";
|
||||
import { messagingDeliveryError, messagingLifecycleError } from "../errors.js";
|
||||
import {
|
||||
messagingLifecycleError,
|
||||
type MessagingDeliveryError,
|
||||
type MessagingLifecycleError,
|
||||
type MessagingTimeoutError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
import { loadMessagingRuntimeConfig } from "../runtime/index.ts";
|
||||
import { makeEffectRequestResponseFromPubSub, type EffectRequestResponse } from "./runtime.js";
|
||||
import {
|
||||
makeEffectRequestResponseFromPubSub,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
} from "./runtime.js";
|
||||
|
||||
export interface RequestResponseOptions {
|
||||
pubsub: PubSubBackend;
|
||||
|
|
@ -22,15 +32,12 @@ export interface RequestResponseOptions {
|
|||
}
|
||||
|
||||
export interface RequestResponse<TReq, TRes> {
|
||||
readonly start: () => Promise<void>;
|
||||
readonly stop: () => Promise<void>;
|
||||
readonly request: (
|
||||
readonly start: Effect.Effect<void, PubSubError | EffectConfig.ConfigError>;
|
||||
readonly stop: Effect.Effect<void>;
|
||||
readonly request: <E = never, R = never>(
|
||||
request: TReq,
|
||||
options?: {
|
||||
timeoutMs?: number;
|
||||
recipient?: (response: TRes) => Promise<boolean>;
|
||||
},
|
||||
) => Promise<TRes>;
|
||||
options?: EffectRequestOptions<TRes, E, R>,
|
||||
) => Effect.Effect<TRes, MessagingDeliveryError | MessagingLifecycleError | MessagingTimeoutError | E, R>;
|
||||
}
|
||||
|
||||
interface RequestResponseRuntime<TReq, TRes> {
|
||||
|
|
@ -43,44 +50,43 @@ export function makeRequestResponse<TReq, TRes>(
|
|||
): RequestResponse<TReq, TRes> {
|
||||
let runtime: RequestResponseRuntime<TReq, TRes> | null = null;
|
||||
|
||||
const start = Effect.fn(`RequestResponse.start:${options.requestTopic}:${options.responseTopic}`)(function* () {
|
||||
if (runtime !== null) return;
|
||||
|
||||
const scope = yield* Scope.make();
|
||||
const startRuntime = Effect.gen(function* () {
|
||||
const config = yield* loadMessagingRuntimeConfig();
|
||||
const requestor = yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(
|
||||
PubSub.fromBackend(options.pubsub),
|
||||
config,
|
||||
{
|
||||
requestTopic: options.requestTopic,
|
||||
responseTopic: options.responseTopic,
|
||||
subscription: options.subscription,
|
||||
},
|
||||
).pipe(Scope.provide(scope));
|
||||
|
||||
runtime = { scope, requestor };
|
||||
});
|
||||
|
||||
yield* startRuntime.pipe(
|
||||
Effect.catch((error) =>
|
||||
Scope.close(scope, Exit.fail(error)).pipe(
|
||||
Effect.flatMap(() => Effect.fail(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
start: () =>
|
||||
runtime !== null
|
||||
? Promise.resolve()
|
||||
: Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make();
|
||||
const startRuntime = Effect.gen(function* () {
|
||||
const config = yield* loadMessagingRuntimeConfig();
|
||||
const requestor = yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(
|
||||
PubSub.fromBackend(options.pubsub),
|
||||
config,
|
||||
{
|
||||
requestTopic: options.requestTopic,
|
||||
responseTopic: options.responseTopic,
|
||||
subscription: options.subscription,
|
||||
},
|
||||
).pipe(Scope.provide(scope));
|
||||
|
||||
runtime = { scope, requestor };
|
||||
});
|
||||
|
||||
yield* startRuntime.pipe(
|
||||
Effect.catch((error) =>
|
||||
Scope.close(scope, Exit.fail(error)).pipe(
|
||||
Effect.flatMap(() => Effect.fail(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
stop: () => {
|
||||
start: start(),
|
||||
stop: Effect.suspend(() => {
|
||||
const current = runtime;
|
||||
runtime = null;
|
||||
return current === null
|
||||
? Promise.resolve()
|
||||
: Effect.runPromise(Scope.close(current.scope, Exit.void));
|
||||
},
|
||||
? Effect.void
|
||||
: Scope.close(current.scope, Exit.void);
|
||||
}),
|
||||
/**
|
||||
* Send a request and wait for responses.
|
||||
*
|
||||
|
|
@ -93,34 +99,21 @@ export function makeRequestResponse<TReq, TRes>(
|
|||
request: (request, requestOptions) => {
|
||||
const current = runtime;
|
||||
if (current === null) {
|
||||
return Effect.runPromise(
|
||||
Effect.fail(
|
||||
messagingLifecycleError(
|
||||
`${options.requestTopic}:${options.responseTopic}`,
|
||||
"request",
|
||||
"RequestResponse not started",
|
||||
),
|
||||
return Effect.fail(
|
||||
messagingLifecycleError(
|
||||
`${options.requestTopic}:${options.responseTopic}`,
|
||||
"request",
|
||||
"RequestResponse not started",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs = requestOptions?.timeoutMs ?? 300_000;
|
||||
const recipient = requestOptions?.recipient;
|
||||
|
||||
return Effect.runPromise(
|
||||
current.requestor.request(request, {
|
||||
timeoutMs,
|
||||
...(recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response) =>
|
||||
Effect.tryPromise({
|
||||
try: () => recipient(response),
|
||||
catch: (error) => messagingDeliveryError(options.responseTopic, "recipient", error),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return current.requestor.request(request, {
|
||||
...requestOptions,
|
||||
timeoutMs,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,10 +168,8 @@ export function makeEffectProducerHandle<T>(
|
|||
): EffectProducer<T> {
|
||||
return {
|
||||
send: Effect.fn(`Producer.send:${options.topic}`)((id: string, message: T) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.send(message, { id }),
|
||||
catch: (error) => messagingDeliveryError(options.topic, "send", error),
|
||||
}).pipe(
|
||||
backend.send(message, { id }).pipe(
|
||||
Effect.mapError((error) => messagingDeliveryError(options.topic, "send", error)),
|
||||
Effect.tap(() =>
|
||||
options.metrics === undefined
|
||||
? Effect.void
|
||||
|
|
@ -179,14 +177,12 @@ export function makeEffectProducerHandle<T>(
|
|||
),
|
||||
),
|
||||
),
|
||||
flush: Effect.tryPromise({
|
||||
try: () => backend.flush(),
|
||||
catch: (error) => messagingDeliveryError(options.topic, "flush", error),
|
||||
}),
|
||||
close: Effect.tryPromise({
|
||||
try: () => backend.close(),
|
||||
catch: (error) => messagingDeliveryError(options.topic, "close", error),
|
||||
}),
|
||||
flush: backend.flush.pipe(
|
||||
Effect.mapError((error) => messagingDeliveryError(options.topic, "flush", error)),
|
||||
),
|
||||
close: backend.close.pipe(
|
||||
Effect.mapError((error) => messagingDeliveryError(options.topic, "close", error)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -219,40 +215,36 @@ const closeConsumerBackend = <T>(
|
|||
topic: string,
|
||||
subscription: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.close(),
|
||||
catch: (error) => messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error),
|
||||
});
|
||||
backend.close.pipe(
|
||||
Effect.mapError((error) => messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error)),
|
||||
);
|
||||
|
||||
const acknowledgeMessage = <T>(
|
||||
backend: BackendConsumer<T>,
|
||||
message: Message<T>,
|
||||
topic: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.acknowledge(message),
|
||||
catch: (error) => messagingDeliveryError(topic, "acknowledge", error),
|
||||
});
|
||||
backend.acknowledge(message).pipe(
|
||||
Effect.mapError((error) => messagingDeliveryError(topic, "acknowledge", error)),
|
||||
);
|
||||
|
||||
const negativeAcknowledgeMessage = <T>(
|
||||
backend: BackendConsumer<T>,
|
||||
message: Message<T>,
|
||||
topic: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.negativeAcknowledge(message),
|
||||
catch: (error) => messagingDeliveryError(topic, "negative-acknowledge", error),
|
||||
});
|
||||
backend.negativeAcknowledge(message).pipe(
|
||||
Effect.mapError((error) => messagingDeliveryError(topic, "negative-acknowledge", error)),
|
||||
);
|
||||
|
||||
const receiveMessage = <T>(
|
||||
backend: BackendConsumer<T>,
|
||||
topic: string,
|
||||
timeoutMs: number,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.receive(timeoutMs),
|
||||
catch: (error) => messagingDeliveryError(topic, "receive", error),
|
||||
});
|
||||
backend.receive(timeoutMs).pipe(
|
||||
Effect.mapError((error) => messagingDeliveryError(topic, "receive", error)),
|
||||
);
|
||||
|
||||
const handleMessageWithRetry = Effect.fn("handleMessageWithRetry")(function* <T, E, R>(
|
||||
options: EffectConsumerOptions<T, E, R>,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import { makeNatsBackend } from "../backend/nats.js";
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect";
|
||||
import { Cause, Config as EffectConfig, Context, Effect } from "effect";
|
||||
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
|
||||
import { loadProcessorRuntimeConfig } from "../runtime/config.js";
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ export interface ProcessorConfig {
|
|||
export type ConfigHandler = (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) => Promise<void>;
|
||||
) => Effect.Effect<void, Cause.UnknownError>;
|
||||
|
||||
export type EffectConfigHandler<E = never, R = never> = (
|
||||
config: Record<string, unknown>,
|
||||
|
|
@ -36,8 +36,10 @@ declare const processorRunRequirementsType: unique symbol;
|
|||
export interface ProcessorRuntime<RunError = ProcessorLifecycleError, RunRequirements = never> {
|
||||
readonly [processorRunErrorType]?: RunError;
|
||||
readonly [processorRunRequirementsType]?: RunRequirements;
|
||||
readonly start: (context: Context.Context<RunRequirements>) => Promise<void>;
|
||||
readonly stop: () => Promise<void>;
|
||||
readonly start: (
|
||||
context: Context.Context<RunRequirements>,
|
||||
) => Effect.Effect<void, RunError | ProcessorLifecycleError>;
|
||||
readonly stop: Effect.Effect<void, ProcessorLifecycleError>;
|
||||
startEffect: Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements>;
|
||||
stopEffect: Effect.Effect<void, ProcessorLifecycleError>;
|
||||
}
|
||||
|
|
@ -48,12 +50,16 @@ export interface AsyncProcessorRuntime<
|
|||
> extends ProcessorRuntime<RunError, RunRequirements> {
|
||||
readonly config: ProcessorConfig;
|
||||
readonly pubsub: PubSubBackend;
|
||||
readonly configHandlers: ConfigHandler[];
|
||||
readonly configHandlers: Array<EffectConfigHandler<RunError | ProcessorLifecycleError, RunRequirements>>;
|
||||
readonly running: boolean;
|
||||
readonly isRunning: () => boolean;
|
||||
readonly registerConfigHandler: (handler: ConfigHandler) => void;
|
||||
readonly onShutdown: (callback: () => Promise<void>) => void;
|
||||
readonly run: (context: Context.Context<RunRequirements>) => Promise<void>;
|
||||
readonly registerConfigHandler: (
|
||||
handler: EffectConfigHandler<RunError | ProcessorLifecycleError, RunRequirements>,
|
||||
) => void;
|
||||
readonly onShutdown: (callback: () => Effect.Effect<void, Cause.UnknownError>) => void;
|
||||
readonly run: (
|
||||
context: Context.Context<RunRequirements>,
|
||||
) => Effect.Effect<void, RunError | ProcessorLifecycleError>;
|
||||
runEffect: Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements>;
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +69,7 @@ export interface AsyncProcessorRuntimeOptions<
|
|||
> {
|
||||
readonly run?: (
|
||||
processor: AsyncProcessorRuntime<RunError, RunRequirements>,
|
||||
) => Promise<void>;
|
||||
) => Effect.Effect<void, RunError, RunRequirements>;
|
||||
readonly runEffect?: (
|
||||
processor: AsyncProcessorRuntime<RunError, RunRequirements>,
|
||||
) => Effect.Effect<void, RunError, RunRequirements>;
|
||||
|
|
@ -74,8 +80,6 @@ interface RegisteredSignalHandler {
|
|||
readonly handler: () => void;
|
||||
}
|
||||
|
||||
const asyncProcessorRuntime = ManagedRuntime.make(Layer.empty);
|
||||
|
||||
export function makeAsyncProcessor<
|
||||
RunError = ProcessorLifecycleError,
|
||||
RunRequirements = never,
|
||||
|
|
@ -85,8 +89,8 @@ export function makeAsyncProcessor<
|
|||
): AsyncProcessorRuntime<RunError, RunRequirements> {
|
||||
const pubsub = config.pubsub ?? makeNatsBackend(config.pubsubUrl ?? "nats://localhost:4222");
|
||||
const ownsPubSub = config.pubsub === undefined;
|
||||
const configHandlers: ConfigHandler[] = [];
|
||||
const shutdownCallbacks: Array<() => Promise<void>> = [];
|
||||
const configHandlers: Array<EffectConfigHandler<RunError | ProcessorLifecycleError, RunRequirements>> = [];
|
||||
const shutdownCallbacks: Array<() => Effect.Effect<void, Cause.UnknownError>> = [];
|
||||
let running = false;
|
||||
let signalHandlers: RegisteredSignalHandler[] = [];
|
||||
|
||||
|
|
@ -96,12 +100,16 @@ export function makeAsyncProcessor<
|
|||
}
|
||||
|
||||
const shutdown = () => {
|
||||
void asyncProcessorRuntime.runPromise(
|
||||
Effect.runFork(
|
||||
Effect.log(`[${config.id}] Shutting down...`).pipe(
|
||||
Effect.flatMap(() => processor.stopEffect),
|
||||
Effect.flatMap(() => processor.stop),
|
||||
Effect.mapError((error) => processorLifecycleError(config.id, "signal-shutdown", error)),
|
||||
Effect.match({
|
||||
onFailure: () => process.exit(1),
|
||||
onSuccess: () => process.exit(0),
|
||||
}),
|
||||
),
|
||||
).then(() => process.exit(0), () => process.exit(1));
|
||||
);
|
||||
};
|
||||
const handlers: RegisteredSignalHandler[] = [
|
||||
{ signal: "SIGINT", handler: shutdown },
|
||||
|
|
@ -131,8 +139,10 @@ export function makeAsyncProcessor<
|
|||
registerConfigHandler: (handler) => {
|
||||
configHandlers.push(handler);
|
||||
},
|
||||
start: (context) => asyncProcessorRuntime.runPromise(Effect.provide(processor.startEffect, context)),
|
||||
stop: () => asyncProcessorRuntime.runPromise(processor.stopEffect),
|
||||
start: (context) => Effect.provide(processor.startEffect, context),
|
||||
get stop() {
|
||||
return processor.stopEffect;
|
||||
},
|
||||
onShutdown: (callback) => {
|
||||
shutdownCallbacks.push(callback);
|
||||
},
|
||||
|
|
@ -161,30 +171,27 @@ export function makeAsyncProcessor<
|
|||
});
|
||||
|
||||
for (const cb of shutdownCallbacks) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => cb(),
|
||||
catch: (error) => processorLifecycleError(config.id, "shutdown-callback", error),
|
||||
});
|
||||
yield* cb().pipe(
|
||||
Effect.mapError((error) => processorLifecycleError(config.id, "shutdown-callback", error)),
|
||||
);
|
||||
}
|
||||
|
||||
if (ownsPubSub) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => pubsub.close(),
|
||||
catch: (error) => processorLifecycleError(config.id, "close-pubsub", error),
|
||||
});
|
||||
yield* pubsub.close.pipe(
|
||||
Effect.mapError((error) => processorLifecycleError(config.id, "close-pubsub", error)),
|
||||
);
|
||||
}
|
||||
});
|
||||
return stopProcessor();
|
||||
},
|
||||
run: (context) => asyncProcessorRuntime.runPromise(Effect.provide(processor.runEffect, context)),
|
||||
run: (context) => Effect.provide(processor.runEffect, context),
|
||||
get runEffect() {
|
||||
if (options.runEffect !== undefined) {
|
||||
return options.runEffect(processor);
|
||||
}
|
||||
return Effect.tryPromise({
|
||||
try: () => options.run?.(processor) ?? Promise.resolve(),
|
||||
catch: (error) => processorLifecycleError(config.id, "start", error),
|
||||
});
|
||||
return options.run?.(processor).pipe(
|
||||
Effect.mapError((error) => processorLifecycleError(config.id, "start", error)),
|
||||
) ?? Effect.void;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -201,21 +208,18 @@ export const AsyncProcessor = Object.assign(
|
|||
return makeAsyncProcessor(config);
|
||||
},
|
||||
{
|
||||
launch<T extends ProcessorRuntime<unknown, never>>(
|
||||
launch<RunError, T extends ProcessorRuntime<RunError, never>>(
|
||||
this: new (config: ProcessorConfig) => T,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
): Effect.Effect<void, ProcessorLifecycleError | EffectConfig.ConfigError> {
|
||||
const ProcessorCtor = this;
|
||||
return asyncProcessorRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* loadProcessorRuntimeConfig(id);
|
||||
const processor = new ProcessorCtor(config);
|
||||
yield* Effect.tryPromise({
|
||||
try: () => processor.start(Context.empty()),
|
||||
catch: (error) => processorLifecycleError(id, "launch", error),
|
||||
});
|
||||
}),
|
||||
);
|
||||
return Effect.gen(function* () {
|
||||
const config = yield* loadProcessorRuntimeConfig(id);
|
||||
const processor = new ProcessorCtor(config);
|
||||
yield* processor.start(Context.empty()).pipe(
|
||||
Effect.mapError((error) => processorLifecycleError(id, "launch", error)),
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
) as unknown as {
|
||||
|
|
@ -225,8 +229,8 @@ export const AsyncProcessor = Object.assign(
|
|||
<RunError = ProcessorLifecycleError, RunRequirements = never>(
|
||||
config: ProcessorConfig,
|
||||
): AsyncProcessor<RunError, RunRequirements>;
|
||||
launch<T extends ProcessorRuntime<unknown, never>>(
|
||||
launch<RunError, T extends ProcessorRuntime<RunError, never>>(
|
||||
this: new (config: ProcessorConfig) => T,
|
||||
id: string,
|
||||
): Promise<void>;
|
||||
): Effect.Effect<void, ProcessorLifecycleError | EffectConfig.ConfigError>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
import {
|
||||
makeAsyncProcessor,
|
||||
type AsyncProcessorRuntime,
|
||||
type ConfigHandler,
|
||||
type EffectConfigHandler,
|
||||
type ProcessorRuntime,
|
||||
type ProcessorConfig,
|
||||
|
|
@ -38,7 +37,7 @@ import {
|
|||
} from "../messaging/runtime.js";
|
||||
import { makePubSubService, PubSub } from "../backend/pubsub.js";
|
||||
import { loadMessagingRuntimeConfig } from "../runtime/index.ts";
|
||||
import { Context, Duration, Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
|
||||
import { Config as EffectConfig, Context, Duration, Effect, Exit, Scope } from "effect";
|
||||
import * as MutableHashMap from "effect/MutableHashMap";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
|
@ -75,22 +74,38 @@ type FlowProcessorRuntimeRequirements<FlowRequirements> =
|
|||
| Scope.Scope
|
||||
| FlowRequirements;
|
||||
|
||||
type FlowProcessorRunError =
|
||||
| PubSubError
|
||||
| FlowRuntimeError
|
||||
| ProcessorLifecycleError
|
||||
| EffectConfig.ConfigError;
|
||||
|
||||
export type FlowProcessorStartEffect<FlowRequirements> = Effect.Effect<
|
||||
void,
|
||||
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
|
||||
FlowProcessorRunError,
|
||||
FlowProcessorRuntimeRequirements<FlowRequirements>
|
||||
>;
|
||||
|
||||
export interface FlowProcessorRuntime<FlowRequirements = never>
|
||||
extends ProcessorRuntime<
|
||||
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
|
||||
FlowProcessorRunError,
|
||||
FlowProcessorRuntimeRequirements<FlowRequirements>
|
||||
> {
|
||||
> {
|
||||
readonly config: ProcessorConfig;
|
||||
readonly pubsub: PubSubBackend;
|
||||
readonly configHandlers: ConfigHandler[];
|
||||
readonly configHandlers: ReadonlyArray<
|
||||
EffectConfigHandler<
|
||||
FlowProcessorRunError,
|
||||
FlowProcessorRuntimeRequirements<FlowRequirements>
|
||||
>
|
||||
>;
|
||||
readonly isRunning: () => boolean;
|
||||
readonly registerConfigHandler: (handler: ConfigHandler) => void;
|
||||
readonly registerConfigHandler: (
|
||||
handler: EffectConfigHandler<
|
||||
FlowProcessorRunError,
|
||||
FlowProcessorRuntimeRequirements<FlowRequirements>
|
||||
>,
|
||||
) => void;
|
||||
readonly registerSpecification: (spec: Spec<FlowRequirements>) => void;
|
||||
readonly specifications: ReadonlyArray<Spec<FlowRequirements>>;
|
||||
}
|
||||
|
|
@ -103,7 +118,7 @@ export interface MakeFlowProcessorOptions<FlowRequirements = never> {
|
|||
}
|
||||
|
||||
const ConfigPushSchema = S.Struct({
|
||||
version: S.Number,
|
||||
version: S.Finite,
|
||||
config: S.Record(S.String, S.Unknown),
|
||||
});
|
||||
|
||||
|
|
@ -162,10 +177,8 @@ export function runFlowProcessorDefinitionScoped<
|
|||
if (consumer === null) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.tryPromise({
|
||||
try: () => consumer.close(),
|
||||
catch: (error) => pubSubError("close:config-push", error),
|
||||
}).pipe(
|
||||
return consumer.close.pipe(
|
||||
Effect.mapError((error) => pubSubError("close:config-push", error)),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError(`[${options.id}] Failed to close config consumer`, {
|
||||
error: error.message,
|
||||
|
|
@ -253,10 +266,9 @@ export function runFlowProcessorDefinitionScoped<
|
|||
return;
|
||||
}
|
||||
|
||||
const msg = yield* Effect.tryPromise({
|
||||
try: () => consumer.receive(2000),
|
||||
catch: (error) => pubSubError("receive:config-push", error),
|
||||
});
|
||||
const msg = yield* consumer.receive(2000).pipe(
|
||||
Effect.mapError((error) => pubSubError("receive:config-push", error)),
|
||||
);
|
||||
if (msg === null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -270,10 +282,9 @@ export function runFlowProcessorDefinitionScoped<
|
|||
yield* handler(push.config, push.version);
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => consumer.acknowledge(msg),
|
||||
catch: (error) => pubSubError("acknowledge:config-push", error),
|
||||
});
|
||||
yield* consumer.acknowledge(msg).pipe(
|
||||
Effect.mapError((error) => pubSubError("acknowledge:config-push", error)),
|
||||
);
|
||||
});
|
||||
|
||||
const processNextConfigPushSafelyEffect = Effect.fn("FlowProcessor.processNextConfigPushSafely")(function* () {
|
||||
|
|
@ -324,29 +335,19 @@ export function makeFlowProcessor<FlowRequirements = never>(
|
|||
const specifications: Array<Spec<FlowRequirements>> = [
|
||||
...(options.specifications ?? []),
|
||||
];
|
||||
const compatibilityRuntime = ManagedRuntime.make(Layer.empty);
|
||||
let processor: FlowProcessorRuntime<FlowRequirements>;
|
||||
const base: AsyncProcessorRuntime<
|
||||
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
|
||||
FlowProcessorRunError,
|
||||
FlowProcessorRuntimeRequirements<FlowRequirements>
|
||||
> = makeAsyncProcessor(config, {
|
||||
runEffect: (runtime) => {
|
||||
const configHandlers = runtime.configHandlers.map(
|
||||
(handler): EffectConfigHandler<PubSubError> =>
|
||||
(pushedConfig, version) =>
|
||||
Effect.tryPromise({
|
||||
try: () => handler(pushedConfig, version),
|
||||
catch: (error) => pubSubError("config-handler", error),
|
||||
}),
|
||||
);
|
||||
return runFlowProcessorDefinitionScoped({
|
||||
runEffect: (runtime) =>
|
||||
runFlowProcessorDefinitionScoped({
|
||||
id: runtime.config.id,
|
||||
pubsub: runtime.pubsub,
|
||||
specifications,
|
||||
configHandlers,
|
||||
configHandlers: runtime.configHandlers,
|
||||
isRunning: runtime.isRunning,
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const makeStartEffect = (): FlowProcessorStartEffect<FlowRequirements> => {
|
||||
|
|
@ -381,7 +382,7 @@ export function makeFlowProcessor<FlowRequirements = never>(
|
|||
get startEffect() {
|
||||
return makeStartEffect();
|
||||
},
|
||||
start: (context) => compatibilityRuntime.runPromise(startProcessorEffect(context)),
|
||||
start: (context) => startProcessorEffect(context),
|
||||
};
|
||||
|
||||
return processor;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/flow.py
|
||||
*/
|
||||
|
||||
import { Config as EffectConfig, Context, Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
|
||||
import { Config as EffectConfig, Context, Effect, Exit, Scope } from "effect";
|
||||
import * as MutableHashMap from "effect/MutableHashMap";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
|
@ -15,6 +15,9 @@ import {
|
|||
flowResourceNotFoundError,
|
||||
type FlowParameterDecodeError,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type MessagingLifecycleError,
|
||||
type MessagingTimeoutError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
import {
|
||||
|
|
@ -43,26 +46,26 @@ export interface FlowDefinition {
|
|||
}
|
||||
|
||||
export interface FlowProducer<T> {
|
||||
readonly send: (id: string, message: T) => Promise<void>;
|
||||
readonly flush: () => Promise<void>;
|
||||
readonly stop: () => Promise<void>;
|
||||
readonly send: (id: string, message: T) => Effect.Effect<void, MessagingDeliveryError>;
|
||||
readonly flush: Effect.Effect<void, MessagingDeliveryError>;
|
||||
readonly stop: Effect.Effect<void, MessagingDeliveryError>;
|
||||
}
|
||||
|
||||
export interface FlowConsumer {
|
||||
readonly stop: () => Promise<void>;
|
||||
readonly stop: Effect.Effect<void, MessagingLifecycleError>;
|
||||
}
|
||||
|
||||
export interface FlowRequestOptions<TRes> {
|
||||
export interface FlowRequestOptions<TRes, E = never, R = never> {
|
||||
readonly timeoutMs?: number;
|
||||
readonly recipient?: (response: TRes) => Promise<boolean>;
|
||||
readonly recipient?: (response: TRes) => Effect.Effect<boolean, E, R>;
|
||||
}
|
||||
|
||||
export interface FlowRequestor<TReq, TRes> {
|
||||
readonly request: (
|
||||
readonly request: <E = never, R = never>(
|
||||
request: TReq,
|
||||
options?: FlowRequestOptions<TRes>,
|
||||
) => Promise<TRes>;
|
||||
readonly stop: () => Promise<void>;
|
||||
options?: FlowRequestOptions<TRes, E, R>,
|
||||
) => Effect.Effect<TRes, MessagingDeliveryError | MessagingLifecycleError | MessagingTimeoutError | E, R>;
|
||||
readonly stop: Effect.Effect<void, MessagingLifecycleError | MessagingDeliveryError>;
|
||||
}
|
||||
|
||||
type FlowParameterError = FlowResourceNotFoundError | FlowParameterDecodeError;
|
||||
|
|
@ -71,19 +74,14 @@ export interface Flow<Requirements = never> {
|
|||
readonly name: string;
|
||||
readonly processorId: string;
|
||||
startEffect: Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements>;
|
||||
start: (context: Context.Context<Requirements>) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
start: (context: Context.Context<Requirements>) => Effect.Effect<void, PubSubError | EffectConfig.ConfigError>;
|
||||
stop: Effect.Effect<void>;
|
||||
stopEffect: Effect.Effect<void>;
|
||||
runInCompatibilityScopeEffect: <A, E>(
|
||||
runInRuntimeScopeEffect: <A, E>(
|
||||
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
|
||||
runtimePubsub: PubSubBackend,
|
||||
context: Context.Context<Requirements>,
|
||||
) => Effect.Effect<A, E | EffectConfig.ConfigError>;
|
||||
runInCompatibilityScope: <A, E>(
|
||||
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
|
||||
runtimePubsub: PubSubBackend,
|
||||
context: Context.Context<Requirements>,
|
||||
) => Promise<A>;
|
||||
clearResources: () => void;
|
||||
registerProducer: <T>(registerName: string, producer: EffectProducer<T>) => void;
|
||||
registerConsumer: (registerName: string, consumer: EffectConsumer) => void;
|
||||
|
|
@ -108,13 +106,15 @@ export interface Flow<Requirements = never> {
|
|||
(parameterName: string): Effect.Effect<unknown, FlowResourceNotFoundError>;
|
||||
};
|
||||
producer: {
|
||||
<T>(producerSpec: ProducerSpec<T>): FlowProducer<T>;
|
||||
(producerName: string): FlowProducer<never>;
|
||||
<T>(producerSpec: ProducerSpec<T>): Effect.Effect<FlowProducer<T>, FlowResourceNotFoundError>;
|
||||
(producerName: string): Effect.Effect<FlowProducer<never>, FlowResourceNotFoundError>;
|
||||
};
|
||||
consumer: (consumerName: string) => FlowConsumer;
|
||||
consumer: (consumerName: string) => Effect.Effect<FlowConsumer, FlowResourceNotFoundError>;
|
||||
requestor: {
|
||||
<TReq, TRes>(requestorSpec: RequestResponseSpec<TReq, TRes>): FlowRequestor<TReq, TRes>;
|
||||
(requestorName: string): FlowRequestor<never, unknown>;
|
||||
<TReq, TRes>(
|
||||
requestorSpec: RequestResponseSpec<TReq, TRes>,
|
||||
): Effect.Effect<FlowRequestor<TReq, TRes>, FlowResourceNotFoundError>;
|
||||
(requestorName: string): Effect.Effect<FlowRequestor<never, unknown>, FlowResourceNotFoundError>;
|
||||
};
|
||||
parameter: {
|
||||
<T>(parameterSpec: ParameterSpec<T>): T;
|
||||
|
|
@ -133,21 +133,20 @@ export function makeFlow<Requirements = never>(
|
|||
const consumers = MutableHashMap.empty<string, EffectConsumer>();
|
||||
const requestors = MutableHashMap.empty<string, EffectRequestResponse<never, unknown>>();
|
||||
const parameters = MutableHashMap.empty<string, unknown>();
|
||||
let compatibilityScope: Scope.Closeable | null = null;
|
||||
const compatibilityRuntime = ManagedRuntime.make(Layer.empty);
|
||||
let runtimeScope: Scope.Closeable | null = null;
|
||||
|
||||
const ensureCompatibilityScopeEffect = Effect.fn("Flow.ensureCompatibilityScope")(function* () {
|
||||
if (compatibilityScope !== null) {
|
||||
return compatibilityScope;
|
||||
const ensureRuntimeScopeEffect = Effect.fn("Flow.ensureRuntimeScope")(function* () {
|
||||
if (runtimeScope !== null) {
|
||||
return runtimeScope;
|
||||
}
|
||||
const scope = yield* Scope.make();
|
||||
compatibilityScope = scope;
|
||||
runtimeScope = scope;
|
||||
return scope;
|
||||
});
|
||||
|
||||
const toEffectRequestOptions = <TRes>(
|
||||
options: FlowRequestOptions<TRes> | undefined,
|
||||
): EffectRequestOptions<TRes> | undefined => {
|
||||
const toEffectRequestOptions = <TRes, E, R>(
|
||||
options: FlowRequestOptions<TRes, E, R> | undefined,
|
||||
): EffectRequestOptions<TRes, E, R> | undefined => {
|
||||
if (options === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -157,7 +156,7 @@ export function makeFlow<Requirements = never>(
|
|||
...(recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response: TRes) => Effect.promise(() => recipient(response)),
|
||||
recipient,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
@ -198,12 +197,6 @@ export function makeFlow<Requirements = never>(
|
|||
: Effect.succeed(producer);
|
||||
};
|
||||
|
||||
const getProducer = (producerName: string): EffectProducer<never> => {
|
||||
const producer = O.getOrUndefined(MutableHashMap.get(producers, producerName));
|
||||
if (producer === undefined) throw flowResourceNotFoundError(name, "producer", producerName);
|
||||
return producer;
|
||||
};
|
||||
|
||||
const getRequestorEffect = (
|
||||
requestorName: string,
|
||||
): Effect.Effect<EffectRequestResponse<never, unknown>, FlowResourceNotFoundError> => {
|
||||
|
|
@ -213,31 +206,21 @@ export function makeFlow<Requirements = never>(
|
|||
: Effect.succeed(requestor);
|
||||
};
|
||||
|
||||
const getRequestor = (
|
||||
requestorName: string,
|
||||
): EffectRequestResponse<never, unknown> => {
|
||||
const requestor = O.getOrUndefined(MutableHashMap.get(requestors, requestorName));
|
||||
if (requestor === undefined) throw flowResourceNotFoundError(name, "requestor", requestorName);
|
||||
return requestor;
|
||||
};
|
||||
|
||||
const toFlowProducer = <T>(producer: EffectProducer<T>): FlowProducer<T> => ({
|
||||
send: (id, message) => compatibilityRuntime.runPromise(producer.send(id, message)),
|
||||
flush: () => compatibilityRuntime.runPromise(producer.flush),
|
||||
stop: () => compatibilityRuntime.runPromise(producer.flush.pipe(Effect.flatMap(() => producer.close))),
|
||||
send: producer.send,
|
||||
flush: producer.flush,
|
||||
stop: producer.flush.pipe(Effect.flatMap(() => producer.close)),
|
||||
});
|
||||
|
||||
const toFlowRequestor = <TReq, TRes>(
|
||||
requestor: EffectRequestResponse<TReq, TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: (request, options) =>
|
||||
compatibilityRuntime.runPromise(
|
||||
requestor.request(
|
||||
request,
|
||||
toEffectRequestOptions(options),
|
||||
),
|
||||
requestor.request(
|
||||
request,
|
||||
toEffectRequestOptions(options),
|
||||
),
|
||||
stop: () => compatibilityRuntime.runPromise(requestor.stop),
|
||||
stop: requestor.stop,
|
||||
});
|
||||
|
||||
function producerEffect<T>(
|
||||
|
|
@ -303,32 +286,26 @@ export function makeFlow<Requirements = never>(
|
|||
return decodeParameter(parameter, value);
|
||||
}
|
||||
|
||||
function producer<T>(producerSpec: ProducerSpec<T>): FlowProducer<T>;
|
||||
function producer(producerName: string): FlowProducer<never>;
|
||||
function producer<T>(producerSpec: ProducerSpec<T>): Effect.Effect<FlowProducer<T>, FlowResourceNotFoundError>;
|
||||
function producer(producerName: string): Effect.Effect<FlowProducer<never>, FlowResourceNotFoundError>;
|
||||
function producer<T>(producer: string | ProducerSpec<T>) {
|
||||
if (typeof producer === "string") {
|
||||
return toFlowProducer(getProducer(producer));
|
||||
return getProducerEffect(producer).pipe(Effect.map(toFlowProducer));
|
||||
}
|
||||
if (!MutableHashMap.has(producers, producer.name)) {
|
||||
throw flowResourceNotFoundError(name, "producer", producer.name);
|
||||
}
|
||||
return toFlowProducer(compatibilityRuntime.runSync(producer.producerEffect(flow)));
|
||||
return producer.producerEffect(flow).pipe(Effect.map(toFlowProducer));
|
||||
}
|
||||
|
||||
function requestor<TReq, TRes>(
|
||||
requestorSpec: RequestResponseSpec<TReq, TRes>,
|
||||
): FlowRequestor<TReq, TRes>;
|
||||
function requestor(requestorName: string): FlowRequestor<never, unknown>;
|
||||
): Effect.Effect<FlowRequestor<TReq, TRes>, FlowResourceNotFoundError>;
|
||||
function requestor(requestorName: string): Effect.Effect<FlowRequestor<never, unknown>, FlowResourceNotFoundError>;
|
||||
function requestor<TReq, TRes>(
|
||||
requestor: string | RequestResponseSpec<TReq, TRes>,
|
||||
) {
|
||||
if (typeof requestor === "string") {
|
||||
return toFlowRequestor(getRequestor(requestor));
|
||||
return getRequestorEffect(requestor).pipe(Effect.map(toFlowRequestor));
|
||||
}
|
||||
if (!MutableHashMap.has(requestors, requestor.name)) {
|
||||
throw flowResourceNotFoundError(name, "requestor", requestor.name);
|
||||
}
|
||||
return toFlowRequestor(compatibilityRuntime.runSync(requestor.requestorEffect(flow)));
|
||||
return requestor.requestorEffect(flow).pipe(Effect.map(toFlowRequestor));
|
||||
}
|
||||
|
||||
const flow: Flow<Requirements> = {
|
||||
|
|
@ -339,33 +316,31 @@ export function makeFlow<Requirements = never>(
|
|||
yield* spec.addEffect(flow, definition);
|
||||
}
|
||||
}).pipe(Effect.withSpan("Flow.startEffect")),
|
||||
start(context: Context.Context<Requirements>): Promise<void> {
|
||||
return compatibilityRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
if (compatibilityScope !== null) {
|
||||
yield* flow.stopEffect;
|
||||
}
|
||||
yield* flow.runInCompatibilityScopeEffect(flow.startEffect, pubsub, context);
|
||||
}),
|
||||
);
|
||||
start(context: Context.Context<Requirements>): Effect.Effect<void, PubSubError | EffectConfig.ConfigError> {
|
||||
return Effect.gen(function* () {
|
||||
if (runtimeScope !== null) {
|
||||
yield* flow.stop;
|
||||
}
|
||||
yield* flow.runInRuntimeScopeEffect(flow.startEffect, pubsub, context);
|
||||
});
|
||||
},
|
||||
stop(): Promise<void> {
|
||||
return compatibilityRuntime.runPromise(flow.stopEffect);
|
||||
get stop() {
|
||||
return flow.stopEffect;
|
||||
},
|
||||
stopEffect: Effect.gen(function* () {
|
||||
const scope = compatibilityScope;
|
||||
compatibilityScope = null;
|
||||
const scope = runtimeScope;
|
||||
runtimeScope = null;
|
||||
if (scope !== null) {
|
||||
yield* Scope.close(scope, Exit.void);
|
||||
}
|
||||
flow.clearResources();
|
||||
}).pipe(Effect.withSpan("Flow.stopEffect")),
|
||||
runInCompatibilityScopeEffect: Effect.fn("Flow.runInCompatibilityScopeEffect")(function* <A, E>(
|
||||
runInRuntimeScopeEffect: Effect.fn("Flow.runInRuntimeScopeEffect")(function* <A, E>(
|
||||
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
|
||||
runtimePubsub: PubSubBackend,
|
||||
context: Context.Context<Requirements>,
|
||||
) {
|
||||
const scope = yield* ensureCompatibilityScopeEffect();
|
||||
const scope = yield* ensureRuntimeScopeEffect();
|
||||
const pubsubService = makePubSubService(runtimePubsub);
|
||||
const messagingConfig = yield* loadMessagingRuntimeConfig();
|
||||
return yield* Effect.provide(
|
||||
|
|
@ -381,13 +356,6 @@ export function makeFlow<Requirements = never>(
|
|||
context,
|
||||
);
|
||||
}),
|
||||
runInCompatibilityScope<A, E>(
|
||||
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
|
||||
runtimePubsub: PubSubBackend,
|
||||
context: Context.Context<Requirements>,
|
||||
): Promise<A> {
|
||||
return compatibilityRuntime.runPromise(flow.runInCompatibilityScopeEffect(effect, runtimePubsub, context));
|
||||
},
|
||||
clearResources(): void {
|
||||
MutableHashMap.clear(producers);
|
||||
MutableHashMap.clear(consumers);
|
||||
|
|
@ -416,12 +384,12 @@ export function makeFlow<Requirements = never>(
|
|||
requestorEffect,
|
||||
parameterEffect,
|
||||
producer,
|
||||
consumer(consumerName: string): FlowConsumer {
|
||||
const c = O.getOrUndefined(MutableHashMap.get(consumers, consumerName));
|
||||
if (c === undefined) throw flowResourceNotFoundError(name, "consumer", consumerName);
|
||||
return {
|
||||
stop: () => compatibilityRuntime.runPromise(c.stop),
|
||||
};
|
||||
consumer(consumerName: string): Effect.Effect<FlowConsumer, FlowResourceNotFoundError> {
|
||||
return flow.consumerEffect(consumerName).pipe(
|
||||
Effect.map((c) => ({
|
||||
stop: c.stop,
|
||||
})),
|
||||
);
|
||||
},
|
||||
requestor,
|
||||
parameter,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
import { Config as EffectConfig, Effect, Layer } from "effect";
|
||||
import {
|
||||
processorLifecycleError,
|
||||
type FlowRuntimeError,
|
||||
type ProcessorLifecycleError,
|
||||
type PubSubError,
|
||||
|
|
@ -83,10 +82,7 @@ export const runProcessorScoped = Effect.fn("runProcessorScoped")(function* <
|
|||
const processor = make(runtimeConfig);
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.tryPromise({
|
||||
try: () => processor.stop(),
|
||||
catch: (error) => processorLifecycleError(config.id, "stop", error),
|
||||
}).pipe(
|
||||
processor.stop.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[Processor] Failed to stop processor", {
|
||||
error: error.message,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const UnknownRecord = S.Record(S.String, S.Unknown);
|
|||
const MutableArray = <A extends S.Top>(schema: A) => schema.pipe(S.Array, S.mutable);
|
||||
const OptionalMutableArray = <A extends S.Top>(schema: A) => schema.pipe(S.Array, S.mutable, S.optionalKey);
|
||||
const StringArray = MutableArray(S.String);
|
||||
const NumberArray = MutableArray(S.Number);
|
||||
const NumberArray = MutableArray(S.Finite);
|
||||
const NumberArrays = MutableArray(NumberArray);
|
||||
|
||||
// Text completion
|
||||
|
|
@ -19,7 +19,7 @@ export const TextCompletionRequest = S.Struct({
|
|||
system: S.String,
|
||||
prompt: S.String,
|
||||
model: S.optionalKey(S.String),
|
||||
temperature: S.optionalKey(S.Number),
|
||||
temperature: S.optionalKey(S.Finite),
|
||||
streaming: S.optionalKey(S.Boolean),
|
||||
});
|
||||
export type TextCompletionRequest = typeof TextCompletionRequest.Type;
|
||||
|
|
@ -27,8 +27,8 @@ export type TextCompletionRequest = typeof TextCompletionRequest.Type;
|
|||
export const TextCompletionResponse = S.Struct({
|
||||
response: S.String,
|
||||
model: S.optionalKey(S.String),
|
||||
inToken: S.optionalKey(S.Number),
|
||||
outToken: S.optionalKey(S.Number),
|
||||
inToken: S.optionalKey(S.Finite),
|
||||
outToken: S.optionalKey(S.Finite),
|
||||
error: S.optionalKey(TgError),
|
||||
endOfStream: S.optionalKey(S.Boolean),
|
||||
});
|
||||
|
|
@ -51,10 +51,10 @@ export type EmbeddingsResponse = typeof EmbeddingsResponse.Type;
|
|||
export const GraphRagRequest = S.Struct({
|
||||
query: S.String,
|
||||
collection: S.optionalKey(S.String),
|
||||
entityLimit: S.optionalKey(S.Number),
|
||||
tripleLimit: S.optionalKey(S.Number),
|
||||
maxSubgraphSize: S.optionalKey(S.Number),
|
||||
maxPathLength: S.optionalKey(S.Number),
|
||||
entityLimit: S.optionalKey(S.Finite),
|
||||
tripleLimit: S.optionalKey(S.Finite),
|
||||
maxSubgraphSize: S.optionalKey(S.Finite),
|
||||
maxPathLength: S.optionalKey(S.Finite),
|
||||
streaming: S.optionalKey(S.Boolean),
|
||||
});
|
||||
export type GraphRagRequest = typeof GraphRagRequest.Type;
|
||||
|
|
@ -126,7 +126,7 @@ export const TriplesQueryRequest = S.Struct({
|
|||
p: S.optionalKey(Term),
|
||||
o: S.optionalKey(Term),
|
||||
collection: S.optionalKey(S.String),
|
||||
limit: S.optionalKey(S.Number),
|
||||
limit: S.optionalKey(S.Finite),
|
||||
});
|
||||
export type TriplesQueryRequest = typeof TriplesQueryRequest.Type;
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ export type TriplesQueryResponse = typeof TriplesQueryResponse.Type;
|
|||
export const GraphEmbeddingsRequest = S.Struct({
|
||||
vectors: NumberArrays,
|
||||
user: S.optionalKey(S.String),
|
||||
limit: S.optionalKey(S.Number),
|
||||
limit: S.optionalKey(S.Finite),
|
||||
collection: S.optionalKey(S.String),
|
||||
});
|
||||
export type GraphEmbeddingsRequest = typeof GraphEmbeddingsRequest.Type;
|
||||
|
|
@ -154,7 +154,7 @@ export type GraphEmbeddingsResponse = typeof GraphEmbeddingsResponse.Type;
|
|||
// Document embeddings query
|
||||
export const DocumentEmbeddingsRequest = S.Struct({
|
||||
vectors: NumberArrays,
|
||||
limit: S.optionalKey(S.Number),
|
||||
limit: S.optionalKey(S.Finite),
|
||||
user: S.optionalKey(S.String),
|
||||
collection: S.optionalKey(S.String),
|
||||
});
|
||||
|
|
@ -162,7 +162,7 @@ export type DocumentEmbeddingsRequest = typeof DocumentEmbeddingsRequest.Type;
|
|||
|
||||
const DocumentEmbeddingChunk = S.Struct({
|
||||
chunkId: S.String,
|
||||
score: S.Number,
|
||||
score: S.Finite,
|
||||
content: S.optionalKey(S.String),
|
||||
});
|
||||
|
||||
|
|
@ -193,7 +193,7 @@ export const ConfigRequest = S.StructWithRest(
|
|||
export type ConfigRequest = typeof ConfigRequest.Type;
|
||||
|
||||
export const ConfigResponse = S.Struct({
|
||||
version: S.optionalKey(S.Number),
|
||||
version: S.optionalKey(S.Finite),
|
||||
values: S.optionalKey(S.Unknown),
|
||||
directory: S.optionalKey(StringArray),
|
||||
config: S.optionalKey(UnknownRecord),
|
||||
|
|
@ -266,7 +266,7 @@ export type Triples = typeof Triples.Type;
|
|||
// Document metadata
|
||||
export const DocumentMetadata = S.Struct({
|
||||
id: S.String,
|
||||
time: S.Number,
|
||||
time: S.Finite,
|
||||
kind: S.String,
|
||||
title: S.String,
|
||||
comments: S.String,
|
||||
|
|
@ -284,7 +284,7 @@ export const ProcessingMetadata = S.Struct({
|
|||
id: S.String,
|
||||
documentId: S.String,
|
||||
"document-id": S.optionalKey(S.String),
|
||||
time: S.Number,
|
||||
time: S.Finite,
|
||||
flow: S.String,
|
||||
user: S.String,
|
||||
collection: S.String,
|
||||
|
|
@ -329,10 +329,10 @@ export const LibrarianRequest = S.StructWithRest(
|
|||
content: S.optionalKey(S.String),
|
||||
user: S.optionalKey(S.String),
|
||||
collection: S.optionalKey(S.String),
|
||||
"total-size": S.optionalKey(S.Number),
|
||||
"chunk-size": S.optionalKey(S.Number),
|
||||
"total-size": S.optionalKey(S.Finite),
|
||||
"chunk-size": S.optionalKey(S.Finite),
|
||||
"upload-id": S.optionalKey(S.String),
|
||||
"chunk-index": S.optionalKey(S.Number),
|
||||
"chunk-index": S.optionalKey(S.Finite),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
|
|
@ -342,10 +342,10 @@ const UploadSessionInfo = S.Struct({
|
|||
"upload-id": S.String,
|
||||
"document-id": S.String,
|
||||
"document-metadata-json": S.String,
|
||||
"total-size": S.Number,
|
||||
"chunk-size": S.Number,
|
||||
"total-chunks": S.Number,
|
||||
"chunks-received": S.Number,
|
||||
"total-size": S.Finite,
|
||||
"chunk-size": S.Finite,
|
||||
"total-chunks": S.Finite,
|
||||
"chunks-received": S.Finite,
|
||||
"created-at": S.String,
|
||||
});
|
||||
|
||||
|
|
@ -363,12 +363,12 @@ export const LibrarianResponse = S.StructWithRest(
|
|||
"document-id": S.optionalKey(S.String),
|
||||
"object-id": S.optionalKey(S.String),
|
||||
"upload-id": S.optionalKey(S.String),
|
||||
"chunk-size": S.optionalKey(S.Number),
|
||||
"chunk-index": S.optionalKey(S.Number),
|
||||
"total-chunks": S.optionalKey(S.Number),
|
||||
"chunks-received": S.optionalKey(S.Number),
|
||||
"bytes-received": S.optionalKey(S.Number),
|
||||
"total-bytes": S.optionalKey(S.Number),
|
||||
"chunk-size": S.optionalKey(S.Finite),
|
||||
"chunk-index": S.optionalKey(S.Finite),
|
||||
"total-chunks": S.optionalKey(S.Finite),
|
||||
"chunks-received": S.optionalKey(S.Finite),
|
||||
"bytes-received": S.optionalKey(S.Finite),
|
||||
"total-bytes": S.optionalKey(S.Finite),
|
||||
"upload-state": S.optionalKey(S.String),
|
||||
"received-chunks": S.optionalKey(NumberArray),
|
||||
"missing-chunks": S.optionalKey(NumberArray),
|
||||
|
|
|
|||
|
|
@ -84,16 +84,16 @@ export type RowSchema = typeof RowSchema.Type;
|
|||
|
||||
export const LlmResult = S.Struct({
|
||||
text: S.String,
|
||||
inToken: S.Number,
|
||||
outToken: S.Number,
|
||||
inToken: S.Finite,
|
||||
outToken: S.Finite,
|
||||
model: S.String,
|
||||
});
|
||||
export type LlmResult = typeof LlmResult.Type;
|
||||
|
||||
export const LlmChunk = S.Struct({
|
||||
text: S.String,
|
||||
inToken: S.NullOr(S.Number),
|
||||
outToken: S.NullOr(S.Number),
|
||||
inToken: S.NullOr(S.Finite),
|
||||
outToken: S.NullOr(S.Finite),
|
||||
model: S.String,
|
||||
isFinal: S.Boolean,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,19 +32,19 @@ export class LlmServiceError extends S.TaggedErrorClass<LlmServiceError>()(
|
|||
},
|
||||
) {}
|
||||
|
||||
export interface LlmProvider {
|
||||
export interface LlmProvider<ProviderError = never> {
|
||||
readonly generateContent: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => Promise<LlmResult>;
|
||||
) => Effect.Effect<LlmResult, ProviderError>;
|
||||
readonly generateContentStream: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => AsyncGenerator<LlmChunk>;
|
||||
) => Stream.Stream<LlmChunk, ProviderError>;
|
||||
readonly supportsStreaming: () => boolean;
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ export interface LlmServiceShape {
|
|||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => AsyncGenerator<LlmChunk>;
|
||||
) => Stream.Stream<LlmChunk, LlmServiceError>;
|
||||
readonly supportsStreaming: () => boolean;
|
||||
}
|
||||
|
||||
|
|
@ -74,24 +74,28 @@ const llmServiceError = (operation: string, cause: unknown) =>
|
|||
message: errorMessage(cause),
|
||||
});
|
||||
|
||||
export const makeLlmServiceShape = (provider: LlmProvider): LlmServiceShape => ({
|
||||
export const makeLlmServiceShape = <ProviderError>(
|
||||
provider: LlmProvider<ProviderError>,
|
||||
): LlmServiceShape => ({
|
||||
generateContent: Effect.fn("Llm.generateContent")((
|
||||
system,
|
||||
prompt,
|
||||
model,
|
||||
temperature,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => provider.generateContent(system, prompt, model, temperature),
|
||||
catch: (cause) => llmServiceError("generate-content", cause),
|
||||
}),
|
||||
provider.generateContent(system, prompt, model, temperature).pipe(
|
||||
Effect.mapError((cause) => llmServiceError("generate-content", cause)),
|
||||
),
|
||||
),
|
||||
generateContentStream: (
|
||||
system,
|
||||
prompt,
|
||||
model,
|
||||
temperature,
|
||||
) => provider.generateContentStream(system, prompt, model, temperature),
|
||||
) =>
|
||||
provider.generateContentStream(system, prompt, model, temperature).pipe(
|
||||
Stream.mapError((cause) => llmServiceError("generate-content-stream", cause)),
|
||||
),
|
||||
supportsStreaming: () => provider.supportsStreaming(),
|
||||
});
|
||||
|
||||
|
|
@ -137,14 +141,11 @@ const sendStreamingResponse = Effect.fn("LlmService.sendStreamingResponse")(func
|
|||
) => Effect.Effect<void, MessagingDeliveryError>;
|
||||
},
|
||||
) {
|
||||
yield* Stream.fromAsyncIterable(
|
||||
llm.generateContentStream(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
),
|
||||
(cause) => llmServiceError("generate-content-stream", cause),
|
||||
yield* llm.generateContentStream(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
).pipe(
|
||||
Stream.runForEach((chunk) =>
|
||||
responseProducer.send(requestId, chunkToResponse(chunk)),
|
||||
|
|
@ -215,12 +216,14 @@ export const makeLlmSpecs = (): ReadonlyArray<Spec<Llm>> => [
|
|||
makeParameterSpec("temperature"),
|
||||
];
|
||||
|
||||
export type LlmService = FlowProcessorRuntime<Llm> & LlmProvider;
|
||||
export type LlmService<ProviderError = never> =
|
||||
& FlowProcessorRuntime<Llm>
|
||||
& LlmProvider<ProviderError>;
|
||||
|
||||
export function makeLlmService(
|
||||
export function makeLlmService<ProviderError>(
|
||||
config: ProcessorConfig,
|
||||
provider: LlmProvider,
|
||||
): LlmService {
|
||||
provider: LlmProvider<ProviderError>,
|
||||
): LlmService<ProviderError> {
|
||||
const service = makeFlowProcessor(config, {
|
||||
specifications: makeLlmSpecs(),
|
||||
provide: (effect) =>
|
||||
|
|
|
|||
|
|
@ -5,24 +5,17 @@
|
|||
*/
|
||||
|
||||
import { Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
import type { Spec } from "./types.js";
|
||||
import type { SpecRuntimeRequirements } from "./types.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
import { type MessageHandler } from "../messaging/consumer.js";
|
||||
import {
|
||||
ConsumerFactory,
|
||||
type EffectMessageHandler,
|
||||
} from "../messaging/runtime.js";
|
||||
import {
|
||||
messagingHandlerError,
|
||||
TooManyRequestsError,
|
||||
type MessagingHandlerError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
|
||||
const isTooManyRequestsError = S.is(TooManyRequestsError);
|
||||
|
||||
declare const ConsumerSpecType: unique symbol;
|
||||
|
||||
export interface ConsumerSpec<T, E = never, R = never> extends Spec<R> {
|
||||
|
|
@ -62,26 +55,5 @@ export function makeConsumerSpec<T, E = never, R = never>(
|
|||
return {
|
||||
name,
|
||||
addEffect,
|
||||
add: (flow, pubsub, definition, context) =>
|
||||
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeConsumerSpecFromPromise<T>(
|
||||
name: string,
|
||||
handler: MessageHandler<T>,
|
||||
concurrency = 1,
|
||||
): ConsumerSpec<T, TooManyRequestsError | MessagingHandlerError> {
|
||||
return makeConsumerSpec<T, TooManyRequestsError | MessagingHandlerError>(
|
||||
name,
|
||||
(message, properties, flow) =>
|
||||
Effect.tryPromise({
|
||||
try: () => handler(message, properties, flow),
|
||||
catch: (error) =>
|
||||
isTooManyRequestsError(error)
|
||||
? error
|
||||
: messagingHandlerError(name, `${flow.id}-${flow.name}-${name}`, error),
|
||||
}),
|
||||
concurrency,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export type { Spec, SpecRuntimeError, SpecRuntimeRequirements } from "./types.js";
|
||||
export { makeConsumerSpec, makeConsumerSpecFromPromise, type ConsumerSpec } from "./consumer-spec.js";
|
||||
export { makeConsumerSpec, type ConsumerSpec } from "./consumer-spec.js";
|
||||
export { makeProducerSpec, type ProducerSpec } from "./producer-spec.js";
|
||||
export { makeParameterSpec, type ParameterSpec } from "./parameter-spec.js";
|
||||
export { makeRequestResponseSpec, type RequestResponseSpec } from "./request-response-spec.js";
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/parameter_spec.py
|
||||
*/
|
||||
|
||||
import { Effect, type Context } from "effect";
|
||||
import { Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import type { SpecRuntimeRequirements } from "./types.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
import type { PubSubError } from "../errors.js";
|
||||
|
|
@ -23,12 +22,6 @@ export interface ParameterSpec<T = unknown> {
|
|||
flow: Flow<Requirements>,
|
||||
definition: FlowDefinition,
|
||||
) => Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements>;
|
||||
readonly add: <Requirements = never>(
|
||||
flow: Flow<Requirements>,
|
||||
pubsub: PubSubBackend,
|
||||
definition: FlowDefinition,
|
||||
context: Context.Context<Requirements>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function makeParameterSpec(name: string): ParameterSpec<unknown>;
|
||||
|
|
@ -51,12 +44,5 @@ export function makeParameterSpec<T>(
|
|||
name,
|
||||
schema: parameterSchema,
|
||||
addEffect,
|
||||
add: <Requirements = never>(
|
||||
flow: Flow<Requirements>,
|
||||
pubsub: PubSubBackend,
|
||||
definition: FlowDefinition,
|
||||
context: Context.Context<Requirements>,
|
||||
) =>
|
||||
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/producer_spec.py
|
||||
*/
|
||||
|
||||
import { Effect, type Context } from "effect";
|
||||
import { Effect } from "effect";
|
||||
import type { SpecRuntimeRequirements } from "./types.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import {
|
||||
flowResourceNotFoundError,
|
||||
type FlowResourceNotFoundError,
|
||||
|
|
@ -27,12 +26,6 @@ export interface ProducerSpec<T> {
|
|||
flow: Flow<Requirements>,
|
||||
definition: FlowDefinition,
|
||||
) => Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements>;
|
||||
readonly add: <Requirements = never>(
|
||||
flow: Flow<Requirements>,
|
||||
pubsub: PubSubBackend,
|
||||
definition: FlowDefinition,
|
||||
context: Context.Context<Requirements>,
|
||||
) => Promise<void>;
|
||||
readonly producerEffect: <Requirements = never>(
|
||||
flow: Flow<Requirements>,
|
||||
) => Effect.Effect<EffectProducer<T>, FlowResourceNotFoundError>;
|
||||
|
|
@ -84,7 +77,5 @@ export function makeProducerSpec<T>(name: string): ProducerSpec<T> {
|
|||
name,
|
||||
producerEffect,
|
||||
addEffect,
|
||||
add: (flow, pubsub, definition, context) =>
|
||||
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/prompt_client_spec.py
|
||||
*/
|
||||
|
||||
import { Effect, type Context } from "effect";
|
||||
import { Effect } from "effect";
|
||||
import type { SpecRuntimeRequirements } from "./types.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import {
|
||||
flowResourceNotFoundError,
|
||||
type FlowResourceNotFoundError,
|
||||
|
|
@ -33,12 +32,6 @@ export interface RequestResponseSpec<TReq, TRes> {
|
|||
flow: Flow<Requirements>,
|
||||
definition: FlowDefinition,
|
||||
) => Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements>;
|
||||
readonly add: <Requirements = never>(
|
||||
flow: Flow<Requirements>,
|
||||
pubsub: PubSubBackend,
|
||||
definition: FlowDefinition,
|
||||
context: Context.Context<Requirements>,
|
||||
) => Promise<void>;
|
||||
readonly requestorEffect: <Requirements = never>(
|
||||
flow: Flow<Requirements>,
|
||||
) => Effect.Effect<EffectRequestResponse<TReq, TRes>, FlowResourceNotFoundError>;
|
||||
|
|
@ -99,7 +92,5 @@ export function makeRequestResponseSpec<TReq, TRes>(
|
|||
name,
|
||||
requestorEffect,
|
||||
addEffect,
|
||||
add: (flow, pubsub, definition, context) =>
|
||||
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/spec.py and siblings
|
||||
*/
|
||||
|
||||
import type { Context, Effect, Scope } from "effect";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import type { Effect, Scope } from "effect";
|
||||
import type {
|
||||
ConsumerFactory,
|
||||
ProducerFactory,
|
||||
|
|
@ -28,10 +27,4 @@ export interface Spec<Requirements = never> {
|
|||
flow: Flow<Requirements>,
|
||||
definition: FlowDefinition,
|
||||
): Effect.Effect<void, SpecRuntimeError, SpecRuntimeRequirements | Requirements>;
|
||||
add(
|
||||
flow: Flow<Requirements>,
|
||||
pubsub: PubSubBackend,
|
||||
definition: FlowDefinition,
|
||||
context: Context.Context<Requirements>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue