2026-04-05 21:09:33 -05:00
|
|
|
/**
|
|
|
|
|
* NATS JetStream backend implementation.
|
|
|
|
|
*
|
|
|
|
|
* Replaces Pulsar as the message broker. NATS JetStream provides
|
|
|
|
|
* at-least-once delivery, consumer groups, and replay — matching
|
|
|
|
|
* the QoS levels used by the Python Pulsar backend.
|
|
|
|
|
*
|
|
|
|
|
* Python reference: trustgraph-base/trustgraph/base/pulsar_backend.py
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
connect,
|
|
|
|
|
type NatsConnection,
|
|
|
|
|
type JetStreamClient,
|
|
|
|
|
type JetStreamManager,
|
2026-04-05 22:44:45 -05:00
|
|
|
type Consumer as NatsJsConsumer,
|
2026-04-05 21:09:33 -05:00
|
|
|
type JsMsg,
|
|
|
|
|
StringCodec,
|
|
|
|
|
AckPolicy,
|
2026-04-05 22:44:45 -05:00
|
|
|
DeliverPolicy,
|
2026-04-05 21:09:33 -05:00
|
|
|
} from "nats";
|
2026-05-12 08:06:58 -05:00
|
|
|
import * as S from "effect/Schema";
|
2026-04-05 21:09:33 -05:00
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
PubSubBackend,
|
|
|
|
|
BackendProducer,
|
|
|
|
|
BackendConsumer,
|
|
|
|
|
CreateProducerOptions,
|
|
|
|
|
CreateConsumerOptions,
|
|
|
|
|
Message,
|
|
|
|
|
} from "./types.js";
|
|
|
|
|
|
|
|
|
|
const sc = StringCodec();
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
interface NatsMessage<T> extends Message<T> {
|
2026-04-05 22:44:45 -05:00
|
|
|
/** Exposed so acknowledge/negativeAcknowledge can access the raw JsMsg */
|
|
|
|
|
readonly _jsMsg: JsMsg;
|
2026-06-01 20:26:47 -05:00
|
|
|
}
|
2026-04-05 21:09:33 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
function makeNatsMessage<T>(msg: JsMsg, decoded: T): NatsMessage<T> {
|
|
|
|
|
return {
|
|
|
|
|
_jsMsg: msg,
|
|
|
|
|
value: () => decoded,
|
|
|
|
|
properties: () => {
|
|
|
|
|
const headers = msg.headers;
|
|
|
|
|
const props: Record<string, string> = {};
|
|
|
|
|
if (headers !== undefined) {
|
|
|
|
|
for (const [key, values] of headers) {
|
|
|
|
|
const value = values[0];
|
|
|
|
|
if (value !== undefined) {
|
|
|
|
|
props[key] = value;
|
|
|
|
|
}
|
2026-05-12 08:06:58 -05:00
|
|
|
}
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
2026-06-01 20:26:47 -05:00
|
|
|
return props;
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
function makeNatsProducer<T>(
|
|
|
|
|
js: JetStreamClient,
|
|
|
|
|
subject: string,
|
|
|
|
|
schema?: S.Top,
|
|
|
|
|
): 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> = {};
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
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.
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
interface InitializableBackendConsumer<T> extends BackendConsumer<T> {
|
|
|
|
|
readonly init: () => Promise<void>;
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
function makeNatsConsumer<T>(
|
|
|
|
|
js: JetStreamClient,
|
|
|
|
|
jsm: JetStreamManager,
|
|
|
|
|
subject: string,
|
|
|
|
|
subscription: string,
|
|
|
|
|
initialPosition: "latest" | "earliest",
|
|
|
|
|
streamName: string,
|
|
|
|
|
schema?: S.Top,
|
|
|
|
|
): 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;
|
|
|
|
|
|
|
|
|
|
await jsm.consumers.add(streamName, {
|
|
|
|
|
durable_name: subscription,
|
|
|
|
|
ack_policy: AckPolicy.Explicit,
|
|
|
|
|
deliver_policy: deliverPolicy,
|
|
|
|
|
filter_subject: subject,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
consumer = await js.consumers.get(streamName, subscription);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
receive: async (timeoutMs = 2000) => {
|
|
|
|
|
if (consumer === null) throw new Error("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;
|
|
|
|
|
|
|
|
|
|
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 () => {
|
|
|
|
|
// 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;
|
|
|
|
|
},
|
|
|
|
|
close: async () => {
|
|
|
|
|
consumer = null;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-05 21:09:33 -05:00
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
|
|
|
|
|
let connection: NatsConnection | null = null;
|
|
|
|
|
let js: JetStreamClient | null = null;
|
|
|
|
|
let jsm: JetStreamManager | null = null;
|
|
|
|
|
const initializedStreams = new Set<string>();
|
|
|
|
|
|
|
|
|
|
const ensureConnected = async (): Promise<void> => {
|
|
|
|
|
if (connection === null) {
|
|
|
|
|
connection = await connect({ servers: url });
|
|
|
|
|
js = connection.jetstream();
|
|
|
|
|
jsm = await connection.jetstreamManager();
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
2026-06-01 20:26:47 -05:00
|
|
|
};
|
2026-04-05 21:09:33 -05:00
|
|
|
|
fix: NATS pipeline bugs, add integration tests and service runners
Fix three critical bugs preventing the NATS message pipeline from working:
- FlowProcessor now subscribes to config-push topic (was missing entirely),
using DeliverPolicy.All to replay config on service restart
- NATS streams use wildcard subjects (tg.flow.>) instead of per-topic
narrow filters that caused 503 errors on publish
- Subscriber dispatch loop has exponential backoff on errors to prevent
tight error loops
Add service runner scripts (gateway, config, LLM) and a 7-test
integration suite that verifies config CRUD, WebSocket round-trip,
and full LLM text-completion through the NATS pipeline.
Fix Docker Compose infra: pin Tempo to v2.6.1, remove deprecated Loki
config fields, add user:0 for volume permissions, remap conflicting
ports (FalkorDB 6380, OTLP 4327/4328).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:41:39 -05:00
|
|
|
/**
|
|
|
|
|
* 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.>"]
|
|
|
|
|
*/
|
2026-06-01 20:26:47 -05:00
|
|
|
const ensureStream = async (subject: string): Promise<string> => {
|
fix: NATS pipeline bugs, add integration tests and service runners
Fix three critical bugs preventing the NATS message pipeline from working:
- FlowProcessor now subscribes to config-push topic (was missing entirely),
using DeliverPolicy.All to replay config on service restart
- NATS streams use wildcard subjects (tg.flow.>) instead of per-topic
narrow filters that caused 503 errors on publish
- Subscriber dispatch loop has exponential backoff on errors to prevent
tight error loops
Add service runner scripts (gateway, config, LLM) and a 7-test
integration suite that verifies config CRUD, WebSocket round-trip,
and full LLM text-completion through the NATS pipeline.
Fix Docker Compose infra: pin Tempo to v2.6.1, remove deprecated Loki
config fields, add user:0 for volume permissions, remap conflicting
ports (FalkorDB 6380, OTLP 4327/4328).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:41:39 -05:00
|
|
|
const parts = subject.split(".");
|
|
|
|
|
const streamName = parts.slice(0, 2).join("_");
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
if (initializedStreams.has(streamName)) return streamName;
|
fix: NATS pipeline bugs, add integration tests and service runners
Fix three critical bugs preventing the NATS message pipeline from working:
- FlowProcessor now subscribes to config-push topic (was missing entirely),
using DeliverPolicy.All to replay config on service restart
- NATS streams use wildcard subjects (tg.flow.>) instead of per-topic
narrow filters that caused 503 errors on publish
- Subscriber dispatch loop has exponential backoff on errors to prevent
tight error loops
Add service runner scripts (gateway, config, LLM) and a 7-test
integration suite that verifies config CRUD, WebSocket round-trip,
and full LLM text-completion through the NATS pipeline.
Fix Docker Compose infra: pin Tempo to v2.6.1, remove deprecated Loki
config fields, add user:0 for volume permissions, remap conflicting
ports (FalkorDB 6380, OTLP 4327/4328).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:41:39 -05:00
|
|
|
|
|
|
|
|
const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`;
|
|
|
|
|
|
2026-06-01 20:26:47 -05:00
|
|
|
const manager = jsm;
|
|
|
|
|
if (manager === null) throw new Error("NATS backend not connected");
|
2026-05-12 08:06:58 -05:00
|
|
|
|
fix: NATS pipeline bugs, add integration tests and service runners
Fix three critical bugs preventing the NATS message pipeline from working:
- FlowProcessor now subscribes to config-push topic (was missing entirely),
using DeliverPolicy.All to replay config on service restart
- NATS streams use wildcard subjects (tg.flow.>) instead of per-topic
narrow filters that caused 503 errors on publish
- Subscriber dispatch loop has exponential backoff on errors to prevent
tight error loops
Add service runner scripts (gateway, config, LLM) and a 7-test
integration suite that verifies config CRUD, WebSocket round-trip,
and full LLM text-completion through the NATS pipeline.
Fix Docker Compose infra: pin Tempo to v2.6.1, remove deprecated Loki
config fields, add user:0 for volume permissions, remap conflicting
ports (FalkorDB 6380, OTLP 4327/4328).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:41:39 -05:00
|
|
|
try {
|
2026-06-01 20:26:47 -05:00
|
|
|
await manager.streams.info(streamName);
|
fix: NATS pipeline bugs, add integration tests and service runners
Fix three critical bugs preventing the NATS message pipeline from working:
- FlowProcessor now subscribes to config-push topic (was missing entirely),
using DeliverPolicy.All to replay config on service restart
- NATS streams use wildcard subjects (tg.flow.>) instead of per-topic
narrow filters that caused 503 errors on publish
- Subscriber dispatch loop has exponential backoff on errors to prevent
tight error loops
Add service runner scripts (gateway, config, LLM) and a 7-test
integration suite that verifies config CRUD, WebSocket round-trip,
and full LLM text-completion through the NATS pipeline.
Fix Docker Compose infra: pin Tempo to v2.6.1, remove deprecated Loki
config fields, add user:0 for volume permissions, remap conflicting
ports (FalkorDB 6380, OTLP 4327/4328).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:41:39 -05:00
|
|
|
} catch {
|
2026-06-01 20:26:47 -05:00
|
|
|
await manager.streams.add({
|
fix: NATS pipeline bugs, add integration tests and service runners
Fix three critical bugs preventing the NATS message pipeline from working:
- FlowProcessor now subscribes to config-push topic (was missing entirely),
using DeliverPolicy.All to replay config on service restart
- NATS streams use wildcard subjects (tg.flow.>) instead of per-topic
narrow filters that caused 503 errors on publish
- Subscriber dispatch loop has exponential backoff on errors to prevent
tight error loops
Add service runner scripts (gateway, config, LLM) and a 7-test
integration suite that verifies config CRUD, WebSocket round-trip,
and full LLM text-completion through the NATS pipeline.
Fix Docker Compose infra: pin Tempo to v2.6.1, remove deprecated Loki
config fields, add user:0 for volume permissions, remap conflicting
ports (FalkorDB 6380, OTLP 4327/4328).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:41:39 -05:00
|
|
|
name: streamName,
|
|
|
|
|
subjects: [wildcardSubject],
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-01 20:26:47 -05:00
|
|
|
initializedStreams.add(streamName);
|
fix: NATS pipeline bugs, add integration tests and service runners
Fix three critical bugs preventing the NATS message pipeline from working:
- FlowProcessor now subscribes to config-push topic (was missing entirely),
using DeliverPolicy.All to replay config on service restart
- NATS streams use wildcard subjects (tg.flow.>) instead of per-topic
narrow filters that caused 503 errors on publish
- Subscriber dispatch loop has exponential backoff on errors to prevent
tight error loops
Add service runner scripts (gateway, config, LLM) and a 7-test
integration suite that verifies config CRUD, WebSocket round-trip,
and full LLM text-completion through the NATS pipeline.
Fix Docker Compose infra: pin Tempo to v2.6.1, remove deprecated Loki
config fields, add user:0 for volume permissions, remap conflicting
ports (FalkorDB 6380, OTLP 4327/4328).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:41:39 -05:00
|
|
|
return streamName;
|
2026-06-01 20:26:47 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|