Migrate strict Effect runtime surfaces

This commit is contained in:
elpresidank 2026-06-02 00:22:04 -05:00
parent f6878d4dd7
commit b4ee2b691f
35 changed files with 1717 additions and 1410 deletions

View file

@ -19,6 +19,8 @@ import {
AckPolicy,
DeliverPolicy,
} from "nats";
import { Effect } from "effect";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
import type {
@ -29,6 +31,7 @@ import type {
CreateConsumerOptions,
Message,
} from "./types.js";
import { pubSubError } from "../errors.js";
const sc = StringCodec();
@ -57,36 +60,61 @@ function makeNatsMessage<T>(msg: JsMsg, decoded: T): NatsMessage<T> {
};
}
const hasJsMsg = Predicate.hasProperty("_jsMsg");
function isAckableJsMsg(value: unknown): value is Pick<JsMsg, "ack" | "nak"> {
if (!Predicate.isObject(value)) return false;
if (!Predicate.hasProperty(value, "ack")) return false;
if (!Predicate.hasProperty(value, "nak")) return false;
return typeof value.ack === "function" && typeof value.nak === "function";
}
function isNatsMessage<T>(message: Message<T>): message is NatsMessage<T> {
return hasJsMsg(message) && isAckableJsMsg(message._jsMsg);
}
function makeNatsProducer<T>(
js: JetStreamClient,
subject: string,
schema?: S.Top,
schema?: S.Codec<T, unknown>,
): BackendProducer<T> {
return {
send: async (message, properties) => {
const encoded = schema !== undefined
? S.encodeUnknownSync(schema as S.Codec<unknown, unknown>)(message)
: message;
const data = sc.encode(JSON.stringify(encoded));
const opts: Record<string, unknown> = {};
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: Record<string, unknown> = {};
if (properties !== undefined && Object.keys(properties).length > 0) {
const { headers } = await import("nats");
const hdrs = headers();
for (const [key, val] of Object.entries(properties)) {
hdrs.append(key, val);
}
opts.headers = hdrs;
}
if (properties !== undefined && Object.keys(properties).length > 0) {
const { headers } = yield* Effect.tryPromise({
try: () => import("nats"),
catch: (error) => pubSubError("import:nats-headers", error),
});
const hdrs = headers();
for (const [key, val] of Object.entries(properties)) {
hdrs.append(key, val);
}
opts.headers = hdrs;
}
await js.publish(subject, data, opts);
},
flush: async () => {
// NATS publishes are flushed on the connection level.
},
close: async () => {
// No per-producer cleanup needed for NATS.
},
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(),
// No per-producer cleanup needed for NATS.
close: () => Promise.resolve(),
};
}
@ -101,60 +129,109 @@ function makeNatsConsumer<T>(
subscription: string,
initialPosition: "latest" | "earliest",
streamName: string,
schema?: S.Top,
schema?: S.Codec<T, unknown>,
): InitializableBackendConsumer<T> {
let consumer: NatsJsConsumer | null = null;
return {
init: async () => {
// Stream is already ensured by makeNatsBackend(). Create or bind to a durable consumer.
try {
consumer = await js.consumers.get(streamName, subscription);
} catch {
const deliverPolicy =
initialPosition === "earliest"
? DeliverPolicy.All
: DeliverPolicy.New;
init: () =>
Effect.runPromise(
Effect.gen(function* () {
const existing = yield* Effect.tryPromise({
try: () => js.consumers.get(streamName, subscription),
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
}).pipe(
Effect.catch(() =>
Effect.gen(function* () {
const deliverPolicy =
initialPosition === "earliest"
? DeliverPolicy.All
: DeliverPolicy.New;
await jsm.consumers.add(streamName, {
durable_name: subscription,
ack_policy: AckPolicy.Explicit,
deliver_policy: deliverPolicy,
filter_subject: subject,
});
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),
});
consumer = await js.consumers.get(streamName, subscription);
}
},
receive: async (timeoutMs = 2000) => {
if (consumer === null) throw new Error("Consumer not initialized");
return yield* Effect.tryPromise({
try: () => js.consumers.get(streamName, subscription),
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, 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 = await consumer.next({ expires: timeoutMs });
if (msg === null) return null;
// 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 = JSON.parse(sc.decode(msg.data));
const decoded = schema !== undefined
? S.decodeUnknownSync(schema as S.Codec<unknown, unknown>)(parsed) as T
: parsed as T;
return makeNatsMessage(msg, decoded);
},
acknowledge: async (message) => {
const natsMsg = message as NatsMessage<T>;
natsMsg._jsMsg.ack();
},
negativeAcknowledge: async (message) => {
const natsMsg = message as NatsMessage<T>;
natsMsg._jsMsg.nak();
},
unsubscribe: async () => {
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.sync(() => {
message._jsMsg.ack();
});
}),
),
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.sync(() => {
message._jsMsg.nak();
});
}),
),
unsubscribe: () => {
// The pull-based consumer does not have a persistent subscription to drain.
// Clearing the reference is sufficient; the durable consumer persists server-side.
consumer = null;
return Promise.resolve();
},
close: async () => {
close: () => {
consumer = null;
return Promise.resolve();
},
};
}
@ -165,19 +242,26 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
let jsm: JetStreamManager | null = null;
const initializedStreams = new Set<string>();
const ensureConnected = async (): Promise<void> => {
const ensureConnected = Effect.fn("NatsBackend.ensureConnected")(function* () {
if (connection === null) {
connection = await connect({ servers: url });
js = connection.jetstream();
jsm = await connection.jetstreamManager();
const conn = yield* Effect.tryPromise({
try: () => connect({ servers: url }),
catch: (error) => pubSubError("connect", error),
});
connection = conn;
js = conn.jetstream();
jsm = yield* Effect.tryPromise({
try: () => conn.jetstreamManager(),
catch: (error) => pubSubError("jetstream-manager", error),
});
}
};
});
/**
* Ensure the stream for a given subject exists with a wildcard filter.
* E.g. subject "tg.flow.config-request" stream "tg_flow" with subjects ["tg.flow.>"]
*/
const ensureStream = async (subject: string): Promise<string> => {
const ensureStream = Effect.fn("NatsBackend.ensureStream")(function* (subject: string) {
const parts = subject.split(".");
const streamName = parts.slice(0, 2).join("_");
@ -186,53 +270,78 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`;
const manager = jsm;
if (manager === null) throw new Error("NATS backend not connected");
if (manager === null) return yield* pubSubError("ensure-stream", "NATS backend not connected");
try {
await manager.streams.info(streamName);
} catch {
await manager.streams.add({
name: streamName,
subjects: [wildcardSubject],
});
}
yield* Effect.tryPromise({
try: () => manager.streams.info(streamName),
catch: (error) => pubSubError(`stream-info:${streamName}`, error),
}).pipe(
Effect.catch(() =>
Effect.tryPromise({
try: () =>
manager.streams.add({
name: streamName,
subjects: [wildcardSubject],
}),
catch: (error) => pubSubError(`stream-add:${streamName}`, error),
}),
),
);
initializedStreams.add(streamName);
return streamName;
};
});
return {
createProducer: async <T>(options: CreateProducerOptions) => {
await ensureConnected();
await ensureStream(options.topic);
const client = js;
if (client === null) throw new Error("NATS backend not connected");
return makeNatsProducer<T>(client, options.topic, options.schema);
},
createConsumer: async <T>(options: CreateConsumerOptions) => {
await ensureConnected();
const streamName = await ensureStream(options.topic);
const client = js;
const manager = jsm;
if (client === null || manager === null) throw new Error("NATS backend not connected");
const consumer = makeNatsConsumer<T>(
client,
manager,
options.topic,
options.subscription,
options.initialPosition ?? "latest",
streamName,
options.schema,
);
await consumer.init();
return consumer;
},
close: async () => {
if (connection !== null) {
await connection.drain();
connection = null;
js = null;
jsm = null;
}
},
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;
}
}),
),
};
}

View file

@ -20,10 +20,10 @@ import { pubSubError } from "../errors.js";
export interface PubSubService {
readonly backend: PubSubBackend;
readonly createProducer: <T>(
options: CreateProducerOptions,
options: CreateProducerOptions<T>,
) => Effect.Effect<BackendProducer<T>, ReturnType<typeof pubSubError>>;
readonly createConsumer: <T>(
options: CreateConsumerOptions,
options: CreateConsumerOptions<T>,
) => Effect.Effect<BackendConsumer<T>, ReturnType<typeof pubSubError>>;
readonly close: Effect.Effect<void, ReturnType<typeof pubSubError>>;
}
@ -41,12 +41,12 @@ export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgrap
export function makePubSubService(backend: PubSubBackend): PubSubService {
return {
backend,
createProducer: <T>(options: CreateProducerOptions) =>
createProducer: <T>(options: CreateProducerOptions<T>) =>
Effect.tryPromise({
try: () => backend.createProducer<T>(options),
catch: (error) => pubSubError(`createProducer:${options.topic}`, error),
}),
createConsumer: <T>(options: CreateConsumerOptions) =>
createConsumer: <T>(options: CreateConsumerOptions<T>) =>
Effect.tryPromise({
try: () => backend.createConsumer<T>(options),
catch: (error) => pubSubError(`createConsumer:${options.topic}`, error),

View file

@ -29,21 +29,21 @@ export interface BackendConsumer<T = unknown> {
export type ConsumerType = "shared" | "exclusive" | "failover";
export type InitialPosition = "latest" | "earliest";
export interface CreateProducerOptions {
export interface CreateProducerOptions<T = unknown> {
topic: string;
schema?: S.Top;
schema?: S.Codec<T, unknown>;
}
export interface CreateConsumerOptions {
export interface CreateConsumerOptions<T = unknown> {
topic: string;
subscription: string;
initialPosition?: InitialPosition;
consumerType?: ConsumerType;
schema?: S.Top;
schema?: S.Codec<T, unknown>;
}
export interface PubSubBackend {
createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>>;
createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>>;
createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>>;
createConsumer<T>(options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>>;
close(): Promise<void>;
}