export class ConversationService {
private readonly db: Db;
private readonly connections: ConnectionManager;
constructor(db: Db, connections: ConnectionManager) {
this.db = db;
this.connections = connections;
}
create(input: CreateConversationOptions): Effect.Effect<Conversation> {
return catchSqlErrorAsDefect(this.createConversationEffect(input));
}
private createConversationEffect(
input: CreateConversationOptions,
): Effect.Effect<Conversation, SqlError> {
return Effect.gen(
function* (this: ConversationService) {
const created = yield* this.insertConversation(input);
yield* this.subscribeCreatedConversation(input, created.id);
yield* this.logConversationCreated(input, created.id);
return created;
}.bind(this),
);
}
/**
* Loads the owner of every requested agent.
* @param agentIds Value supplied to the operation.
* @internal
* @returns The rows result.
*/
loadAgentOwners(
agentIds: readonly AgentId[],
): Effect.Effect<
ReadonlyMap<AgentId, UserId>,
AgentNotFoundError | SqlError
> {
return Effect.gen(
function* (this: ConversationService) {
const rows =
agentIds.length === 0
? []
: yield* this.db
.selectFrom("agents")
.select(["id", "owner_user_id"])
.where("id", "in", [...agentIds]);
const ownerByAgentId = new Map<AgentId, UserId>();
for (const row of rows) {
ownerByAgentId.set(row.id, row.owner_user_id);
}
for (const agentId of agentIds) {
if (!ownerByAgentId.has(agentId)) {
return yield* new AgentNotFoundError({
message: `Agent ${agentId} not found`,
});
}
}
return ownerByAgentId;
}.bind(this),
);
}
/**
* Rejects a membership that exceeds the group limit. The caller passes the
* resulting member count; membership is fixed at creation, so this is the
* only capacity gate.
* @param memberCount Value supplied to the operation.
* @internal
* @returns The capacity assertion result.
*/
assertGroupCapacity(
memberCount: number,
): Effect.Effect<void, ConversationFullError> {
if (memberCount <= MAX_GROUP_PARTICIPANTS) {
return Effect.void;
}
return Effect.fail(
new ConversationFullError({ message: GROUP_OVERFLOW_MSG }),
);
}
private insertConversation(
input: CreateConversationOptions,
): Effect.Effect<Conversation, SqlError> {
return transaction(this.db, (trx) =>
Effect.gen(
function* (this: ConversationService) {
const conv = yield* takeFirstOrFail(
trx
.insertInto("conversations")
.values({
name: input.name ?? null,
created_by_id: input.creatorAgentId,
})
.returningAll(),
);
// The creator joins the conversation it opens; membership is the
// creator plus every named participant.
yield* trx.insertInto("conversation_participants").values({
conversation_id: conv.id,
agent_id: input.creatorAgentId,
});
for (const agentId of input.agentIds) {
yield* trx
.insertInto("conversation_participants")
.values({ conversation_id: conv.id, agent_id: agentId })
.onConflict((oc) => oc.doNothing());
}
return mapConversation(conv);
}.bind(this),
),
);
}
private subscribeCreatedConversation(
input: CreateConversationOptions,
conversationId: ConversationId,
): Effect.Effect<void> {
// Mirrors `insertConversation`'s membership set.