Enable project-scoped reusable tags for Direct chat sessions. - Add tag storage, migrations, and scoped assignment APIs. - Add chat sidebar tag creation, assignment, and filtering controls. - Document tag behavior and cover schema and chat interactions. - Establish the session project/id candidate key before creating scoped tag foreign keys. Files changed: .changeset/fn-8562-chat-conversation-tags.md | 7 ++ docs/dashboard-guide.md | 4 + docs/storage.md | 2 + .../src/__tests__/postgres/schema-applier.test.ts | 8 +- packages/core/src/async-chat-store.ts | 117 ++++++++++++++++++++- packages/core/src/chat-store.ts | 32 ++++++ packages/core/src/chat-types.ts | 14 +++ packages/core/src/index.gate.ts | 3 + packages/core/src/index.ts | 3 + .../postgres/migrations/0036_chat_session_tags.sql | 64 +++++++++++ packages/core/src/postgres/schema-applier.ts | 16 ++- packages/core/src/postgres/schema/project.ts | 34 ++++++ packages/dashboard/app/api.ts | 1 + packages/dashboard/app/api/chat.ts | 18 ++++ packages/dashboard/app/components/ChatView.css | 17 +++ packages/dashboard/app/components/ChatView.tsx | 70 +++++++++++- .../__tests__/ChatView.core-interactions.test.tsx | 27 +++++ .../dashboard/app/hooks/__tests__/useChat.test.ts | 1 + packages/dashboard/app/hooks/useChat.ts | 52 ++++++++- .../dashboard/src/routes/register-chat-routes.ts | 70 ++++++++++++ 20 files changed, 550 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-8562 Fusion-Task-Lineage: 4efe798c-0865-451a-adbd-6b85f9f8b149 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
1204 lines
46 KiB
TypeScript
1204 lines
46 KiB
TypeScript
/**
|
|
* Async Drizzle ChatStore helpers (U6 satellite-db-injected-stores).
|
|
*
|
|
* FNXC:ChatStore 2026-06-24-09:00:
|
|
* Async equivalents of the sync SQLite ChatStore call sites in chat-store.ts.
|
|
* These helpers target the PostgreSQL `project.chat_sessions`,
|
|
* `project.chat_messages`, `project.chat_rooms`, `project.chat_room_members`,
|
|
* and `project.chat_room_messages` tables via Drizzle.
|
|
*
|
|
* SQLite → PostgreSQL notes (VAL-SCHEMA-004):
|
|
* The JSON columns (inFlightGeneration, metadata, attachments, mentions)
|
|
* are jsonb in PostgreSQL, so Drizzle returns them already-parsed.
|
|
*
|
|
* Transition context (see library/satellite-store-migration-pattern.md):
|
|
* `getDatabase()` still returns the sync `Database` until the coordinated
|
|
* flip. These helpers are the async target the PostgreSQL integration tests
|
|
* consume.
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import { and, asc, desc, eq, gt, ilike, inArray, isNull, lte, ne, or as orFn, sql as drizzleSql } from "drizzle-orm";
|
|
import * as schema from "./postgres/schema/index.js";
|
|
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
|
|
import { sanitizeTextValue, sanitizeJsonbValue } from "./postgres/nul-sanitize.js";
|
|
import type {
|
|
ChatAttachment,
|
|
ChatInFlightGenerationState,
|
|
ChatMessage,
|
|
ChatMessageRole,
|
|
ChatRoom,
|
|
ChatRoomMember,
|
|
ChatRoomMessage,
|
|
ChatRoomStatus,
|
|
ChatSession,
|
|
ChatSessionStatus,
|
|
ChatTag,
|
|
ChatTagCreateInput,
|
|
ChatTagUpdateInput,
|
|
RoomMemberRole,
|
|
} from "./chat-types.js";
|
|
|
|
/** A query-capable handle: either the top-level db or a transaction handle. */
|
|
type QueryHandle = AsyncDataLayer["db"] | DbTransaction;
|
|
|
|
// ── Row → Entity converters ──
|
|
|
|
function rowToTag(row: Record<string, unknown>): ChatTag {
|
|
return { id: row.id as string, projectId: (row.ownerProjectId as string) === "__default__" ? null : row.ownerProjectId as string, name: row.name as string, createdAt: row.createdAt as string, updatedAt: row.updatedAt as string };
|
|
}
|
|
|
|
function rowToSession(row: Record<string, unknown>): ChatSession {
|
|
return {
|
|
id: row.id as string,
|
|
tags: [],
|
|
agentId: row.agentId as string,
|
|
title: (row.title as string | null) ?? null,
|
|
status: row.status as ChatSessionStatus,
|
|
// FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011).
|
|
projectId: (row.ownerProjectId as string | null) ?? null,
|
|
modelProvider: (row.modelProvider as string | null) ?? null,
|
|
modelId: (row.modelId as string | null) ?? null,
|
|
thinkingLevel: (row.thinkingLevel as string | null) ?? null,
|
|
createdAt: row.createdAt as string,
|
|
updatedAt: row.updatedAt as string,
|
|
pinnedAt: (row.pinnedAt as string | null) ?? null,
|
|
cliSessionFile: (row.cliSessionFile as string | null) ?? null,
|
|
inFlightGeneration: (row.inFlightGeneration as ChatInFlightGenerationState | null) ?? null,
|
|
cliExecutorAdapterId: (row.cliExecutorAdapterId as string | null) ?? null,
|
|
};
|
|
}
|
|
|
|
function rowToMessage(row: Record<string, unknown>): ChatMessage {
|
|
return {
|
|
id: row.id as string,
|
|
sessionId: row.sessionId as string,
|
|
role: row.role as ChatMessageRole,
|
|
content: row.content as string,
|
|
thinkingOutput: (row.thinkingOutput as string | null) ?? null,
|
|
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
|
attachments: (row.attachments as ChatAttachment[] | null) ?? undefined,
|
|
createdAt: row.createdAt as string,
|
|
};
|
|
}
|
|
|
|
function rowToRoom(row: Record<string, unknown>): ChatRoom {
|
|
return {
|
|
id: row.id as string,
|
|
name: row.name as string,
|
|
slug: row.slug as string,
|
|
description: (row.description as string | null) ?? null,
|
|
// FNXC:MultiProjectIsolation 2026-07-15-23:40: domain projectId reads from owner_project_id (see rowToSession).
|
|
projectId: (row.ownerProjectId as string | null) ?? null,
|
|
createdBy: (row.createdBy as string | null) ?? null,
|
|
status: row.status as ChatRoomStatus,
|
|
// FNXC:Chat-ThinkingLevel 2026-07-13 (merge port): room-level reasoning-effort default.
|
|
thinkingLevel: (row.thinkingLevel as ChatRoom["thinkingLevel"] | null) ?? null,
|
|
createdAt: row.createdAt as string,
|
|
updatedAt: row.updatedAt as string,
|
|
};
|
|
}
|
|
|
|
function rowToRoomMember(row: Record<string, unknown>): ChatRoomMember {
|
|
return {
|
|
roomId: row.roomId as string,
|
|
agentId: row.agentId as string,
|
|
role: row.role as RoomMemberRole,
|
|
addedAt: row.addedAt as string,
|
|
};
|
|
}
|
|
|
|
function rowToRoomMessage(row: Record<string, unknown>): ChatRoomMessage {
|
|
return {
|
|
id: row.id as string,
|
|
roomId: row.roomId as string,
|
|
role: row.role as ChatMessageRole,
|
|
content: row.content as string,
|
|
thinkingOutput: (row.thinkingOutput as string | null) ?? null,
|
|
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
|
attachments: (row.attachments as ChatAttachment[] | null) ?? undefined,
|
|
senderAgentId: (row.senderAgentId as string | null) ?? null,
|
|
mentions: (row.mentions as string[]) ?? [],
|
|
createdAt: row.createdAt as string,
|
|
};
|
|
}
|
|
|
|
// ── Session CRUD ──
|
|
|
|
/**
|
|
* Create a chat session.
|
|
*/
|
|
export async function createChatSession(handle: QueryHandle, session: ChatSession): Promise<ChatSession> {
|
|
await handle.insert(schema.project.chatSessions).values({
|
|
id: session.id,
|
|
agentId: session.agentId,
|
|
title: session.title,
|
|
status: session.status,
|
|
// FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — the trigger/GUC owns the partition.
|
|
ownerProjectId: session.projectId,
|
|
modelProvider: session.modelProvider,
|
|
modelId: session.modelId,
|
|
thinkingLevel: session.thinkingLevel ?? null,
|
|
createdAt: session.createdAt,
|
|
updatedAt: session.updatedAt,
|
|
pinnedAt: session.pinnedAt,
|
|
cliSessionFile: session.cliSessionFile,
|
|
inFlightGeneration: session.inFlightGeneration,
|
|
cliExecutorAdapterId: session.cliExecutorAdapterId,
|
|
});
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Get a chat session by id.
|
|
*/
|
|
export async function getChatSession(handle: QueryHandle, id: string): Promise<ChatSession | undefined> {
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatSessions)
|
|
.where(eq(schema.project.chatSessions.id, id));
|
|
return rows[0] ? attachTags(handle, rowToSession(rows[0])) : undefined;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-09:05:
|
|
* List chat sessions with optional filtering, ordered by updatedAt DESC.
|
|
*/
|
|
export async function listChatSessions(
|
|
handle: QueryHandle,
|
|
options?: { projectId?: string; agentId?: string; status?: ChatSessionStatus },
|
|
): Promise<ChatSession[]> {
|
|
const conditions: ReturnType<typeof eq>[] = [];
|
|
if (options?.projectId) conditions.push(eq(schema.project.chatSessions.ownerProjectId, options.projectId));
|
|
if (options?.agentId) conditions.push(eq(schema.project.chatSessions.agentId, options.agentId));
|
|
if (options?.status) conditions.push(eq(schema.project.chatSessions.status, options.status));
|
|
const query = handle
|
|
.select()
|
|
.from(schema.project.chatSessions)
|
|
.orderBy(desc(schema.project.chatSessions.updatedAt));
|
|
const rows = conditions.length > 0 ? await query.where(and(...conditions)) : await query;
|
|
return attachTagsToSessions(handle, rows.map(rowToSession));
|
|
}
|
|
|
|
/**
|
|
* Delete a chat session by id. Returns true if a row was deleted.
|
|
*/
|
|
export async function deleteChatSession(handle: QueryHandle, id: string): Promise<boolean> {
|
|
const [session] = await handle.select({ projectId: schema.project.chatSessions.projectId })
|
|
.from(schema.project.chatSessions).where(eq(schema.project.chatSessions.id, id));
|
|
if (!session) return false;
|
|
const sessionProjectId = session.projectId;
|
|
if (!sessionProjectId) throw new Error("Chat session is missing its required project partition");
|
|
// FNXC:ChatTags 2026-08-05-12:15: cleanup retains the session's RLS partition even for bypass/admin handles; an unqualified ID could delete another project's assignment.
|
|
await handle.delete(schema.project.chatSessionTags).where(and(
|
|
eq(schema.project.chatSessionTags.sessionId, id),
|
|
eq(schema.project.chatSessionTags.projectId, sessionProjectId),
|
|
));
|
|
const result = await handle
|
|
.delete(schema.project.chatSessions)
|
|
.where(and(eq(schema.project.chatSessions.id, id), eq(schema.project.chatSessions.projectId, sessionProjectId)))
|
|
.returning({ id: schema.project.chatSessions.id });
|
|
return result.length > 0;
|
|
}
|
|
|
|
function tagScope(projectId: string | null | undefined): string { return projectId ?? "__default__"; }
|
|
function normalizeTagName(name: string): { name: string; normalizedName: string } {
|
|
const cleaned = name.trim().replace(/\s+/g, " ");
|
|
if (!cleaned || cleaned.length > 64) throw new Error("Tag name must be between 1 and 64 characters");
|
|
return { name: cleaned, normalizedName: cleaned.toLocaleLowerCase() };
|
|
}
|
|
|
|
async function attachTags(handle: QueryHandle, session: ChatSession): Promise<ChatSession> {
|
|
const [attached] = await attachTagsToSessions(handle, [session]);
|
|
return attached ?? session;
|
|
}
|
|
|
|
async function attachTagsToSessions(handle: QueryHandle, sessions: ChatSession[]): Promise<ChatSession[]> {
|
|
if (!sessions.length) return sessions;
|
|
const rows = await handle.select({ sessionId: schema.project.chatSessionTags.sessionId, id: schema.project.chatTags.id, ownerProjectId: schema.project.chatTags.ownerProjectId, name: schema.project.chatTags.name, createdAt: schema.project.chatTags.createdAt, updatedAt: schema.project.chatTags.updatedAt })
|
|
.from(schema.project.chatSessionTags).innerJoin(schema.project.chatTags, and(
|
|
eq(schema.project.chatSessionTags.tagId, schema.project.chatTags.id),
|
|
eq(schema.project.chatSessionTags.projectId, schema.project.chatTags.projectId),
|
|
))
|
|
.innerJoin(schema.project.chatSessions, and(
|
|
eq(schema.project.chatSessionTags.sessionId, schema.project.chatSessions.id),
|
|
eq(schema.project.chatSessionTags.projectId, schema.project.chatSessions.projectId),
|
|
))
|
|
.where(inArray(schema.project.chatSessionTags.sessionId, sessions.map((session) => session.id))).orderBy(asc(schema.project.chatTags.normalizedName), asc(schema.project.chatTags.id));
|
|
const tagsBySession = new Map<string, ChatTag[]>();
|
|
for (const row of rows) { const tags = tagsBySession.get(row.sessionId) ?? []; tags.push(rowToTag(row)); tagsBySession.set(row.sessionId, tags); }
|
|
return sessions.map((session) => ({ ...session, tags: tagsBySession.get(session.id) ?? [] }));
|
|
}
|
|
|
|
/** FNXC:ChatTags 2026-08-05-10:55: Tags normalize whitespace/case and are always read in deterministic name order. */
|
|
export async function listChatTags(handle: QueryHandle, projectId: string | null): Promise<ChatTag[]> {
|
|
const rows = await handle.select().from(schema.project.chatTags).where(eq(schema.project.chatTags.ownerProjectId, tagScope(projectId))).orderBy(asc(schema.project.chatTags.normalizedName), asc(schema.project.chatTags.id));
|
|
return rows.map(rowToTag);
|
|
}
|
|
|
|
export async function createChatTag(layer: AsyncDataLayer, input: ChatTagCreateInput): Promise<ChatTag> {
|
|
const { name, normalizedName } = normalizeTagName(input.name); const projectId = input.projectId ?? null; const now = new Date().toISOString();
|
|
return layer.transactionImmediate(async (tx) => {
|
|
const id = `chat-tag-${randomUUID().slice(0, 8)}`;
|
|
const rows = await tx.insert(schema.project.chatTags).values({ id, ownerProjectId: tagScope(projectId), name, normalizedName, createdAt: now, updatedAt: now }).onConflictDoNothing().returning();
|
|
if (!rows[0]) throw new Error("A tag with that name already exists");
|
|
return rowToTag(rows[0]);
|
|
});
|
|
}
|
|
|
|
export async function renameChatTag(layer: AsyncDataLayer, id: string, projectId: string | null, input: ChatTagUpdateInput): Promise<ChatTag | undefined> {
|
|
const { name, normalizedName } = normalizeTagName(input.name);
|
|
return layer.transactionImmediate(async (tx) => { const rows = await tx.update(schema.project.chatTags).set({ name, normalizedName, updatedAt: new Date().toISOString() }).where(and(eq(schema.project.chatTags.id, id), eq(schema.project.chatTags.ownerProjectId, tagScope(projectId)))).returning(); if (!rows[0]) return undefined; return rowToTag(rows[0]); });
|
|
}
|
|
|
|
export async function deleteChatTag(layer: AsyncDataLayer, id: string, projectId: string | null): Promise<boolean> {
|
|
return layer.transactionImmediate(async (tx) => {
|
|
const [tag] = await tx.select({ projectId: schema.project.chatTags.projectId })
|
|
.from(schema.project.chatTags)
|
|
.where(and(eq(schema.project.chatTags.id, id), eq(schema.project.chatTags.ownerProjectId, tagScope(projectId))));
|
|
if (!tag?.projectId) return false;
|
|
const tagProjectId = tag.projectId;
|
|
// FNXC:ChatTags 2026-08-05-12:15: validate the scoped parent before cleanup; bypass handles must not erase a same-ID tag assignment in another partition.
|
|
await tx.delete(schema.project.chatSessionTags).where(and(
|
|
eq(schema.project.chatSessionTags.tagId, id),
|
|
eq(schema.project.chatSessionTags.projectId, tagProjectId),
|
|
));
|
|
return (await tx.delete(schema.project.chatTags)
|
|
.where(and(eq(schema.project.chatTags.id, id), eq(schema.project.chatTags.projectId, tagProjectId)))
|
|
.returning({ id: schema.project.chatTags.id })).length > 0;
|
|
});
|
|
}
|
|
|
|
export async function replaceChatSessionTags(layer: AsyncDataLayer, sessionId: string, projectId: string | null, tagIds: string[]): Promise<ChatSession | undefined> {
|
|
return layer.transactionImmediate(async (tx) => {
|
|
const session = await getChatSessionForUpdate(tx, sessionId); if (!session || session.projectId !== projectId) return undefined;
|
|
const ownerProjectCondition = projectId === null
|
|
? isNull(schema.project.chatSessions.ownerProjectId)
|
|
: eq(schema.project.chatSessions.ownerProjectId, projectId);
|
|
const [sessionPartition] = await tx.select({ projectId: schema.project.chatSessions.projectId }).from(schema.project.chatSessions)
|
|
.where(and(eq(schema.project.chatSessions.id, sessionId), ownerProjectCondition)).for("update");
|
|
if (!sessionPartition?.projectId) return undefined;
|
|
const sessionProjectId = sessionPartition.projectId;
|
|
const uniqueIds = [...new Set(tagIds)];
|
|
const tags = uniqueIds.length ? await tx.select().from(schema.project.chatTags).where(and(
|
|
inArray(schema.project.chatTags.id, uniqueIds),
|
|
eq(schema.project.chatTags.ownerProjectId, tagScope(projectId)),
|
|
eq(schema.project.chatTags.projectId, sessionProjectId),
|
|
)) : [];
|
|
if (tags.length !== uniqueIds.length) throw new Error("One or more tags do not belong to this project");
|
|
// FNXC:ChatTags 2026-08-05-12:15: replacements are scoped by the locked session partition, preserving project isolation when the DB bypass role is active.
|
|
await tx.delete(schema.project.chatSessionTags).where(and(eq(schema.project.chatSessionTags.sessionId, sessionId), eq(schema.project.chatSessionTags.projectId, sessionProjectId)));
|
|
if (uniqueIds.length) await tx.insert(schema.project.chatSessionTags).values(uniqueIds.map((tagId) => ({ projectId: sessionProjectId, sessionId, tagId, assignedAt: new Date().toISOString() }))).onConflictDoNothing();
|
|
return attachTags(tx, session);
|
|
});
|
|
}
|
|
|
|
// ── Message CRUD ──
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-09:10:
|
|
* Add a message to a chat session and bump the session's updatedAt.
|
|
*/
|
|
export async function addChatMessage(
|
|
handle: QueryHandle,
|
|
message: ChatMessage,
|
|
): Promise<ChatMessage> {
|
|
// FNXC:PostgresMigrationNulSanitize 2026-07-20: agent/tool output persisted
|
|
// here can contain a raw NUL byte (e.g. piped-through Windows CLI dumps),
|
|
// which Postgres text/jsonb columns reject outright. Sanitize before
|
|
// insert instead of letting the write throw mid-conversation, and return
|
|
// the sanitized value so the in-memory result matches what was persisted
|
|
// (the original unsanitized `message` object must not be handed back).
|
|
const sanitizedAttachments = message.attachments === undefined
|
|
? undefined
|
|
: sanitizeJsonbValue(message.attachments);
|
|
const sanitized: ChatMessage = {
|
|
...message,
|
|
content: sanitizeTextValue(message.content),
|
|
thinkingOutput: sanitizeTextValue(message.thinkingOutput),
|
|
metadata: sanitizeJsonbValue(message.metadata),
|
|
attachments: sanitizedAttachments,
|
|
};
|
|
await handle.insert(schema.project.chatMessages).values({
|
|
id: sanitized.id,
|
|
sessionId: sanitized.sessionId,
|
|
role: sanitized.role,
|
|
content: sanitized.content,
|
|
thinkingOutput: sanitized.thinkingOutput,
|
|
metadata: sanitized.metadata,
|
|
attachments: sanitizedAttachments ?? null,
|
|
createdAt: sanitized.createdAt,
|
|
});
|
|
await handle
|
|
.update(schema.project.chatSessions)
|
|
.set({ updatedAt: sanitized.createdAt })
|
|
.where(eq(schema.project.chatSessions.id, sanitized.sessionId));
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* Get a chat message by id.
|
|
*/
|
|
export async function getChatMessage(handle: QueryHandle, id: string): Promise<ChatMessage | undefined> {
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatMessages)
|
|
.where(eq(schema.project.chatMessages.id, id));
|
|
return rows[0] ? rowToMessage(rows[0]) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Get messages for a chat session with optional filtering.
|
|
*/
|
|
export async function getChatMessages(
|
|
handle: QueryHandle,
|
|
sessionId: string,
|
|
filter?: { limit?: number; offset?: number; before?: string; order?: "asc" | "desc" },
|
|
): Promise<ChatMessage[]> {
|
|
const conditions: ReturnType<typeof eq>[] = [eq(schema.project.chatMessages.sessionId, sessionId)];
|
|
if (filter?.before) {
|
|
conditions.push(lte(schema.project.chatMessages.createdAt, filter.before));
|
|
}
|
|
const limit = filter?.limit ?? 100;
|
|
const offset = filter?.offset ?? 0;
|
|
const orderCol = schema.project.chatMessages.createdAt;
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatMessages)
|
|
.where(and(...conditions))
|
|
.orderBy(filter?.order === "desc" ? desc(orderCol) : asc(orderCol))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
return rows.map(rowToMessage);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-09:15:
|
|
* Get the latest message for each session in the provided list.
|
|
*/
|
|
export async function getLastMessageForSessions(
|
|
handle: QueryHandle,
|
|
sessionIds: string[],
|
|
): Promise<Map<string, ChatMessage>> {
|
|
if (sessionIds.length === 0) return new Map();
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatMessages)
|
|
.where(inArray(schema.project.chatMessages.sessionId, sessionIds))
|
|
.orderBy(
|
|
desc(schema.project.chatMessages.createdAt),
|
|
desc(schema.project.chatMessages.id),
|
|
);
|
|
const result = new Map<string, ChatMessage>();
|
|
for (const row of rows) {
|
|
const msg = rowToMessage(row);
|
|
if (!result.has(msg.sessionId)) {
|
|
result.set(msg.sessionId, msg);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Room CRUD ──
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-09:20:
|
|
* Create a chat room + initial members atomically inside a transaction.
|
|
*/
|
|
export async function createChatRoom(
|
|
layer: AsyncDataLayer,
|
|
room: ChatRoom,
|
|
memberAgentIds: string[],
|
|
): Promise<{ room: ChatRoom; members: ChatRoomMember[] }> {
|
|
const now = room.createdAt;
|
|
await layer.transactionImmediate(async (tx) => {
|
|
await tx.insert(schema.project.chatRooms).values({
|
|
id: room.id,
|
|
name: room.name,
|
|
slug: room.slug,
|
|
description: room.description,
|
|
ownerProjectId: room.projectId,
|
|
createdBy: room.createdBy,
|
|
status: room.status,
|
|
thinkingLevel: room.thinkingLevel ?? null,
|
|
createdAt: room.createdAt,
|
|
updatedAt: room.updatedAt,
|
|
});
|
|
for (const agentId of memberAgentIds) {
|
|
const role: RoomMemberRole = room.createdBy !== null && agentId === room.createdBy ? "owner" : "member";
|
|
await tx.insert(schema.project.chatRoomMembers).values({
|
|
roomId: room.id,
|
|
agentId,
|
|
role,
|
|
addedAt: now,
|
|
});
|
|
}
|
|
});
|
|
const members = await listChatRoomMembers(layer.db, room.id);
|
|
return { room, members };
|
|
}
|
|
|
|
/**
|
|
* Get a chat room by id.
|
|
*/
|
|
export async function getChatRoom(handle: QueryHandle, id: string): Promise<ChatRoom | undefined> {
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatRooms)
|
|
.where(eq(schema.project.chatRooms.id, id));
|
|
return rows[0] ? rowToRoom(rows[0]) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Get a chat room by (projectId, slug).
|
|
*/
|
|
export async function getChatRoomBySlug(
|
|
handle: QueryHandle,
|
|
projectId: string | null,
|
|
slug: string,
|
|
): Promise<ChatRoom | undefined> {
|
|
const conditions = [eq(schema.project.chatRooms.slug, slug)];
|
|
if (projectId !== null) {
|
|
conditions.push(eq(schema.project.chatRooms.ownerProjectId, projectId));
|
|
} else {
|
|
conditions.push(isNull(schema.project.chatRooms.ownerProjectId));
|
|
}
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatRooms)
|
|
.where(and(...conditions));
|
|
return rows[0] ? rowToRoom(rows[0]) : undefined;
|
|
}
|
|
|
|
/**
|
|
* List chat rooms with optional filtering, ordered by updatedAt DESC.
|
|
*/
|
|
export async function listChatRooms(
|
|
handle: QueryHandle,
|
|
options?: { projectId?: string; status?: ChatRoomStatus },
|
|
): Promise<ChatRoom[]> {
|
|
const conditions: ReturnType<typeof eq>[] = [];
|
|
if (options?.projectId) conditions.push(eq(schema.project.chatRooms.ownerProjectId, options.projectId));
|
|
if (options?.status) conditions.push(eq(schema.project.chatRooms.status, options.status));
|
|
const query = handle
|
|
.select()
|
|
.from(schema.project.chatRooms)
|
|
.orderBy(desc(schema.project.chatRooms.updatedAt));
|
|
const rows = conditions.length > 0 ? await query.where(and(...conditions)) : await query;
|
|
return rows.map(rowToRoom);
|
|
}
|
|
|
|
/**
|
|
* Delete a chat room by id. Returns true if a row was deleted.
|
|
*/
|
|
export async function deleteChatRoom(handle: QueryHandle, id: string): Promise<boolean> {
|
|
const result = await handle
|
|
.delete(schema.project.chatRooms)
|
|
.where(eq(schema.project.chatRooms.id, id))
|
|
.returning({ id: schema.project.chatRooms.id });
|
|
return result.length > 0;
|
|
}
|
|
|
|
// ── Room Member CRUD ──
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-09:25:
|
|
* Add a room member. Uses ON CONFLICT DO NOTHING to match the sync
|
|
* INSERT OR IGNORE behavior.
|
|
*/
|
|
export async function addChatRoomMember(
|
|
handle: QueryHandle,
|
|
roomId: string,
|
|
agentId: string,
|
|
role: RoomMemberRole,
|
|
addedAt: string,
|
|
): Promise<void> {
|
|
await handle
|
|
.insert(schema.project.chatRoomMembers)
|
|
.values({ roomId, agentId, role, addedAt })
|
|
.onConflictDoNothing();
|
|
}
|
|
|
|
/**
|
|
* Remove a room member. Returns true if a row was deleted.
|
|
*/
|
|
export async function removeChatRoomMember(
|
|
handle: QueryHandle,
|
|
roomId: string,
|
|
agentId: string,
|
|
): Promise<boolean> {
|
|
const result = await handle
|
|
.delete(schema.project.chatRoomMembers)
|
|
.where(
|
|
and(
|
|
eq(schema.project.chatRoomMembers.roomId, roomId),
|
|
eq(schema.project.chatRoomMembers.agentId, agentId),
|
|
),
|
|
)
|
|
.returning({ roomId: schema.project.chatRoomMembers.roomId });
|
|
return result.length > 0;
|
|
}
|
|
|
|
/**
|
|
* List room members ordered by addedAt ASC.
|
|
*/
|
|
export async function listChatRoomMembers(handle: QueryHandle, roomId: string): Promise<ChatRoomMember[]> {
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatRoomMembers)
|
|
.where(eq(schema.project.chatRoomMembers.roomId, roomId))
|
|
.orderBy(asc(schema.project.chatRoomMembers.addedAt));
|
|
return rows.map(rowToRoomMember);
|
|
}
|
|
|
|
// ── Room Message CRUD ──
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-09:30:
|
|
* Add a room message and bump the room's updatedAt.
|
|
*/
|
|
export async function addChatRoomMessage(
|
|
handle: QueryHandle,
|
|
message: ChatRoomMessage,
|
|
): Promise<ChatRoomMessage> {
|
|
// FNXC:PostgresMigrationNulSanitize 2026-07-20: same NUL-byte hazard as
|
|
// addChatMessage above — sanitize before insert, and return the sanitized
|
|
// value so the in-memory result matches what was persisted.
|
|
const sanitizedAttachments = message.attachments === undefined
|
|
? undefined
|
|
: sanitizeJsonbValue(message.attachments);
|
|
const sanitized: ChatRoomMessage = {
|
|
...message,
|
|
content: sanitizeTextValue(message.content),
|
|
thinkingOutput: sanitizeTextValue(message.thinkingOutput),
|
|
metadata: sanitizeJsonbValue(message.metadata),
|
|
attachments: sanitizedAttachments,
|
|
};
|
|
await handle.insert(schema.project.chatRoomMessages).values({
|
|
id: sanitized.id,
|
|
roomId: sanitized.roomId,
|
|
role: sanitized.role,
|
|
content: sanitized.content,
|
|
thinkingOutput: sanitized.thinkingOutput,
|
|
metadata: sanitized.metadata,
|
|
attachments: sanitizedAttachments ?? null,
|
|
senderAgentId: sanitized.senderAgentId,
|
|
mentions: sanitized.mentions,
|
|
createdAt: sanitized.createdAt,
|
|
});
|
|
await handle
|
|
.update(schema.project.chatRooms)
|
|
.set({ updatedAt: sanitized.createdAt })
|
|
.where(eq(schema.project.chatRooms.id, sanitized.roomId));
|
|
return sanitized;
|
|
}
|
|
|
|
/**
|
|
* Get a room message by id.
|
|
*/
|
|
export async function getChatRoomMessage(handle: QueryHandle, id: string): Promise<ChatRoomMessage | undefined> {
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatRoomMessages)
|
|
.where(eq(schema.project.chatRoomMessages.id, id));
|
|
return rows[0] ? rowToRoomMessage(rows[0]) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Get room messages with optional filtering.
|
|
*/
|
|
export async function getChatRoomMessages(
|
|
handle: QueryHandle,
|
|
roomId: string,
|
|
filter?: { limit?: number; offset?: number; before?: string; order?: "asc" | "desc" },
|
|
): Promise<ChatRoomMessage[]> {
|
|
const conditions: ReturnType<typeof eq>[] = [eq(schema.project.chatRoomMessages.roomId, roomId)];
|
|
if (filter?.before) {
|
|
conditions.push(lte(schema.project.chatRoomMessages.createdAt, filter.before));
|
|
}
|
|
const limit = filter?.limit ?? 100;
|
|
const offset = filter?.offset ?? 0;
|
|
const orderCol = schema.project.chatRoomMessages.createdAt;
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatRoomMessages)
|
|
.where(and(...conditions))
|
|
.orderBy(filter?.order === "desc" ? desc(orderCol) : asc(orderCol))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
return rows.map(rowToRoomMessage);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-09:35:
|
|
* Clear all room messages. Returns the count of deleted messages.
|
|
*/
|
|
export async function clearChatRoomMessages(handle: QueryHandle, roomId: string): Promise<number> {
|
|
const result = await handle
|
|
.delete(schema.project.chatRoomMessages)
|
|
.where(eq(schema.project.chatRoomMessages.roomId, roomId))
|
|
.returning({ id: schema.project.chatRoomMessages.id });
|
|
return result.length;
|
|
}
|
|
|
|
// ── FNXC:RuntimeSatelliteCompletion 2026-06-24-22:00:
|
|
// The following helpers complete the async ChatStore surface so every method
|
|
// that previously threw in backend mode now delegates to PostgreSQL via Drizzle.
|
|
// These mirror the sync SQLite semantics in chat-store.ts exactly. The matching
|
|
// backend-mode branches in chat-store.ts call these helpers instead of throwing.
|
|
|
|
/**
|
|
* Lock a session row before a pin or archive mutation.
|
|
*
|
|
* FNXC:ChatPinned 2026-07-16-12:30: Pinning and archiving must serialize on
|
|
* the same session row. Reading under this lock prevents an archive from
|
|
* clearing a pin before a concurrent pin request writes it back.
|
|
*/
|
|
export async function getChatSessionForUpdate(
|
|
tx: DbTransaction,
|
|
id: string,
|
|
): Promise<ChatSession | undefined> {
|
|
const rows = await tx
|
|
.select()
|
|
.from(schema.project.chatSessions)
|
|
.where(eq(schema.project.chatSessions.id, id))
|
|
.for("update");
|
|
const row = rows[0];
|
|
return row ? rowToSession(row) : undefined;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:05:
|
|
* Update a chat session's mutable fields (title, status, modelProvider,
|
|
* modelId) and bump updatedAt. Returns the updated session, or undefined if
|
|
* not found. Mirrors sync ChatStore.updateSession.
|
|
*/
|
|
export async function updateChatSession(
|
|
handle: QueryHandle,
|
|
id: string,
|
|
input: {
|
|
title?: string | null;
|
|
status?: ChatSessionStatus;
|
|
modelProvider?: string | null;
|
|
modelId?: string | null;
|
|
thinkingLevel?: string | null;
|
|
pinnedAt?: string | null;
|
|
},
|
|
): Promise<ChatSession | undefined> {
|
|
const existing = await getChatSession(handle, id);
|
|
if (!existing) return undefined;
|
|
|
|
const now = new Date().toISOString();
|
|
const setValues: Record<string, unknown> = { updatedAt: now };
|
|
if (input.title !== undefined) setValues.title = input.title;
|
|
if (input.status !== undefined) setValues.status = input.status;
|
|
if (input.modelProvider !== undefined) setValues.modelProvider = input.modelProvider;
|
|
if (input.modelId !== undefined) setValues.modelId = input.modelId;
|
|
if (input.thinkingLevel !== undefined) setValues.thinkingLevel = input.thinkingLevel;
|
|
if (input.pinnedAt !== undefined) setValues.pinnedAt = input.pinnedAt;
|
|
// FNXC:ChatPinned 2026-07-16-12:00: archiving clears the persisted pin in
|
|
// this same update, including callers that bypass archiveChatSession.
|
|
if (input.status === "archived") setValues.pinnedAt = null;
|
|
|
|
await handle
|
|
.update(schema.project.chatSessions)
|
|
.set(setValues)
|
|
.where(eq(schema.project.chatSessions.id, id));
|
|
|
|
return getChatSession(handle, id);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:05:
|
|
* Archive a chat session (sets status to "archived"). Returns the archived
|
|
* session, or undefined if not found. Mirrors sync ChatStore.archiveSession.
|
|
*/
|
|
export async function archiveChatSession(
|
|
handle: QueryHandle,
|
|
id: string,
|
|
): Promise<ChatSession | undefined> {
|
|
return updateChatSession(handle, id, { status: "archived" });
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:10:
|
|
* Set the CLI session file path for a chat session. Internal plumbing — does
|
|
* not bump updatedAt or emit events. Mirrors sync ChatStore.setCliSessionFile.
|
|
*/
|
|
export async function setCliSessionFile(
|
|
handle: QueryHandle,
|
|
id: string,
|
|
cliSessionFile: string | null,
|
|
): Promise<void> {
|
|
await handle
|
|
.update(schema.project.chatSessions)
|
|
.set({ cliSessionFile })
|
|
.where(eq(schema.project.chatSessions.id, id));
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:10:
|
|
* Set or clear the cli-agent adapter id for a chat session. Bumps updatedAt
|
|
* and returns the updated session. Mirrors sync ChatStore.setCliExecutorAdapterId.
|
|
*/
|
|
export async function setCliExecutorAdapterId(
|
|
handle: QueryHandle,
|
|
id: string,
|
|
adapterId: string | null,
|
|
): Promise<ChatSession | undefined> {
|
|
const existing = await getChatSession(handle, id);
|
|
if (!existing) return undefined;
|
|
await handle
|
|
.update(schema.project.chatSessions)
|
|
.set({ cliExecutorAdapterId: adapterId, updatedAt: new Date().toISOString() })
|
|
.where(eq(schema.project.chatSessions.id, id));
|
|
return getChatSession(handle, id);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:15:
|
|
* Set or clear the in-flight generation state for a chat session. Does not
|
|
* bump updatedAt (the snapshot is transient UI state). Returns the updated
|
|
* session. Mirrors sync ChatStore.setInFlightGeneration.
|
|
*/
|
|
export async function setInFlightGeneration(
|
|
handle: QueryHandle,
|
|
id: string,
|
|
inFlightGeneration: ChatInFlightGenerationState | null,
|
|
): Promise<ChatSession | undefined> {
|
|
const existing = await getChatSession(handle, id);
|
|
if (!existing) return undefined;
|
|
await handle
|
|
.update(schema.project.chatSessions)
|
|
.set({ inFlightGeneration })
|
|
.where(eq(schema.project.chatSessions.id, id));
|
|
return getChatSession(handle, id);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:20:
|
|
* Append a file attachment metadata record to an existing message's
|
|
* attachments jsonb array. Returns the updated message. Throws if the message
|
|
* does not exist in the given session. Mirrors sync ChatStore.addMessageAttachment.
|
|
*/
|
|
export async function addChatMessageAttachment(
|
|
handle: QueryHandle,
|
|
sessionId: string,
|
|
messageId: string,
|
|
attachment: ChatAttachment,
|
|
): Promise<ChatMessage> {
|
|
const message = await getChatMessage(handle, messageId);
|
|
if (!message || message.sessionId !== sessionId) {
|
|
throw new Error(`Message ${messageId} not found in session ${sessionId}`);
|
|
}
|
|
const updatedAttachments = [...(message.attachments ?? []), attachment];
|
|
await handle
|
|
.update(schema.project.chatMessages)
|
|
.set({ attachments: updatedAttachments })
|
|
.where(eq(schema.project.chatMessages.id, messageId));
|
|
const updated = await getChatMessage(handle, messageId);
|
|
if (!updated) throw new Error(`Failed to update message ${messageId}`);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:20:
|
|
* Delete a chat message by id and bump the parent session's updatedAt.
|
|
* Returns true if deleted, false if not found. Mirrors sync ChatStore.deleteMessage.
|
|
*/
|
|
export async function deleteChatMessage(
|
|
handle: QueryHandle,
|
|
id: string,
|
|
): Promise<boolean> {
|
|
const existing = await getChatMessage(handle, id);
|
|
if (!existing) return false;
|
|
await handle.delete(schema.project.chatMessages).where(eq(schema.project.chatMessages.id, id));
|
|
await handle
|
|
.update(schema.project.chatSessions)
|
|
.set({ updatedAt: new Date().toISOString() })
|
|
.where(eq(schema.project.chatSessions.id, existing.sessionId));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatSearch 2026-07-07-00:00:
|
|
* Postgres counterpart of the sync ChatStore.searchSessionsByMessageContent (FN-7631 Chat
|
|
* sidebar content search). Parameterized ILIKE with `%`/`_`/`\` escaped so literal wildcards
|
|
* in the user's search text match literally (SQLite LIKE is ASCII case-insensitive, so ILIKE
|
|
* preserves those semantics). One row per session: the most recent matching message wins,
|
|
* tiebroken by id since Postgres has no rowid; preview truncated to ~100 chars.
|
|
*/
|
|
export async function searchChatSessionsByMessageContent(
|
|
handle: QueryHandle,
|
|
query: string,
|
|
sessionIds: string[],
|
|
): Promise<Map<string, string>> {
|
|
const trimmed = query.trim();
|
|
if (!trimmed || sessionIds.length === 0) return new Map();
|
|
const escaped = trimmed.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatMessages)
|
|
.where(and(
|
|
inArray(schema.project.chatMessages.sessionId, sessionIds),
|
|
ilike(schema.project.chatMessages.content, `%${escaped}%`),
|
|
))
|
|
.orderBy(
|
|
desc(schema.project.chatMessages.createdAt),
|
|
desc(schema.project.chatMessages.id),
|
|
);
|
|
const result = new Map<string, string>();
|
|
for (const row of rows) {
|
|
const message = rowToMessage(row);
|
|
if (result.has(message.sessionId)) continue;
|
|
const content = message.content || "";
|
|
result.set(message.sessionId, content.length > 100 ? content.slice(0, 100) + "…" : content);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatMessageEdit 2026-07-07-09:00:
|
|
* Truncate a chat session from (and including) a target message onward — the Postgres
|
|
* counterpart of the sync ChatStore.deleteMessagesFrom (FN-7628 edit/rewind). Postgres has
|
|
* no rowid insertion-order tiebreaker, so ordering is (createdAt ASC, id ASC) — deterministic
|
|
* and consistent with getLastMessageForSessions' (createdAt DESC, id DESC).
|
|
* Returns deletedIds (ASC order) and retained pre-edit messages (ASC order).
|
|
*/
|
|
export async function deleteChatMessagesFrom(
|
|
handle: QueryHandle,
|
|
sessionId: string,
|
|
fromMessageId: string,
|
|
): Promise<{ deletedIds: string[]; retained: ChatMessage[] }> {
|
|
const orderedRows = await handle
|
|
.select()
|
|
.from(schema.project.chatMessages)
|
|
.where(eq(schema.project.chatMessages.sessionId, sessionId))
|
|
.orderBy(
|
|
asc(schema.project.chatMessages.createdAt),
|
|
asc(schema.project.chatMessages.id),
|
|
);
|
|
const ordered = orderedRows.map(rowToMessage);
|
|
|
|
const target = await getChatMessage(handle, fromMessageId);
|
|
if (!target || target.sessionId !== sessionId) {
|
|
return { deletedIds: [], retained: ordered };
|
|
}
|
|
|
|
const targetIndex = ordered.findIndex((message) => message.id === fromMessageId);
|
|
if (targetIndex === -1) {
|
|
return { deletedIds: [], retained: ordered };
|
|
}
|
|
|
|
const retained = ordered.slice(0, targetIndex);
|
|
const deletedIds = ordered.slice(targetIndex).map((message) => message.id);
|
|
if (deletedIds.length === 0) {
|
|
return { deletedIds: [], retained };
|
|
}
|
|
|
|
await handle
|
|
.delete(schema.project.chatMessages)
|
|
.where(inArray(schema.project.chatMessages.id, deletedIds));
|
|
await handle
|
|
.update(schema.project.chatSessions)
|
|
.set({ updatedAt: new Date().toISOString() })
|
|
.where(eq(schema.project.chatSessions.id, sessionId));
|
|
|
|
return { deletedIds, retained };
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatMessageEdit 2026-07-07-09:00:
|
|
* Merge (default) or replace a persisted message's metadata — Postgres counterpart of the
|
|
* sync ChatStore.updateMessageMetadata (FN-7628). Records e.g. `metadata.piParentLeafId`
|
|
* on a user message so a later edit can rewind the pi session losslessly.
|
|
*/
|
|
export async function updateChatMessageMetadata(
|
|
handle: QueryHandle,
|
|
messageId: string,
|
|
metadata: Record<string, unknown> | null,
|
|
options?: { merge?: boolean },
|
|
): Promise<ChatMessage> {
|
|
const existing = await getChatMessage(handle, messageId);
|
|
if (!existing) {
|
|
throw new Error(`Message ${messageId} not found`);
|
|
}
|
|
|
|
const merge = options?.merge !== false;
|
|
const nextMetadata = metadata === null
|
|
? (merge ? existing.metadata : null)
|
|
: (merge ? { ...(existing.metadata ?? {}), ...metadata } : metadata);
|
|
|
|
await handle
|
|
.update(schema.project.chatMessages)
|
|
.set({ metadata: nextMetadata ?? null })
|
|
.where(eq(schema.project.chatMessages.id, messageId));
|
|
|
|
const updated = await getChatMessage(handle, messageId);
|
|
if (!updated) {
|
|
throw new Error(`Failed to update message ${messageId}`);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:25:
|
|
* Update a chat room's mutable fields (name, slug, description, status) and
|
|
* bump updatedAt. Returns the updated room, or undefined if not found.
|
|
* Mirrors sync ChatStore.updateRoom.
|
|
*/
|
|
export async function updateChatRoom(
|
|
handle: QueryHandle,
|
|
id: string,
|
|
input: {
|
|
name?: string;
|
|
slug?: string;
|
|
description?: string | null;
|
|
status?: ChatRoomStatus;
|
|
thinkingLevel?: ChatRoom["thinkingLevel"] | null;
|
|
},
|
|
): Promise<ChatRoom | undefined> {
|
|
const existing = await getChatRoom(handle, id);
|
|
if (!existing) return undefined;
|
|
|
|
const setValues: Record<string, unknown> = { updatedAt: new Date().toISOString() };
|
|
if (input.name !== undefined) setValues.name = input.name;
|
|
if (input.slug !== undefined) setValues.slug = input.slug;
|
|
if (input.description !== undefined) setValues.description = input.description;
|
|
if (input.status !== undefined) setValues.status = input.status;
|
|
if (input.thinkingLevel !== undefined) setValues.thinkingLevel = input.thinkingLevel;
|
|
|
|
await handle
|
|
.update(schema.project.chatRooms)
|
|
.set(setValues)
|
|
.where(eq(schema.project.chatRooms.id, id));
|
|
|
|
return getChatRoom(handle, id);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:30:
|
|
* Delete stale chat sessions and rooms older than the cutoff timestamp.
|
|
* Returns the count of deleted sessions and rooms. Mirrors sync
|
|
* ChatStore.cleanupOldChats.
|
|
*/
|
|
export async function cleanupOldChats(
|
|
handle: QueryHandle,
|
|
maxAgeMs: number,
|
|
): Promise<{ sessionsDeleted: number; roomsDeleted: number; deletedSessionIds: string[]; deletedRoomIds: string[] }> {
|
|
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
|
|
return { sessionsDeleted: 0, roomsDeleted: 0, deletedSessionIds: [], deletedRoomIds: [] };
|
|
}
|
|
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
|
|
const staleSessions = await handle
|
|
.delete(schema.project.chatSessions)
|
|
.where(lte(schema.project.chatSessions.updatedAt, cutoff))
|
|
.returning({ id: schema.project.chatSessions.id });
|
|
|
|
const staleRooms = await handle
|
|
.delete(schema.project.chatRooms)
|
|
.where(lte(schema.project.chatRooms.updatedAt, cutoff))
|
|
.returning({ id: schema.project.chatRooms.id });
|
|
|
|
return {
|
|
sessionsDeleted: staleSessions.length,
|
|
roomsDeleted: staleRooms.length,
|
|
deletedSessionIds: staleSessions.map((r) => r.id),
|
|
deletedRoomIds: staleRooms.map((r) => r.id),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:30:
|
|
* List rooms that a given agent is a member of, with optional project/status
|
|
* filtering, ordered by room updatedAt DESC. Mirrors sync
|
|
* ChatStore.listRoomsForAgent.
|
|
*/
|
|
export async function listChatRoomsForAgent(
|
|
handle: QueryHandle,
|
|
agentId: string,
|
|
options?: { projectId?: string; status?: ChatRoomStatus },
|
|
): Promise<ChatRoom[]> {
|
|
// Use a subquery to find room IDs where the agent is a member, then select
|
|
// those rooms. This avoids the Drizzle join result-shape complexity.
|
|
const memberRoomIds = handle
|
|
.select({ roomId: schema.project.chatRoomMembers.roomId })
|
|
.from(schema.project.chatRoomMembers)
|
|
.where(eq(schema.project.chatRoomMembers.agentId, agentId));
|
|
|
|
const conditions: ReturnType<typeof eq>[] = [inArray(schema.project.chatRooms.id, memberRoomIds)];
|
|
if (options?.status) conditions.push(eq(schema.project.chatRooms.status, options.status));
|
|
if (options?.projectId) conditions.push(eq(schema.project.chatRooms.ownerProjectId, options.projectId));
|
|
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatRooms)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(schema.project.chatRooms.updatedAt));
|
|
return rows.map(rowToRoom);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:35:
|
|
* List room messages created after a given timestamp, optionally excluding
|
|
* messages from a specific sender. Ordered by createdAt ASC. Mirrors sync
|
|
* ChatStore.listRoomMessagesSince.
|
|
*/
|
|
export async function listChatRoomMessagesSince(
|
|
handle: QueryHandle,
|
|
roomId: string,
|
|
sinceIso: string,
|
|
options?: { excludeSenderAgentId?: string; limit?: number },
|
|
): Promise<ChatRoomMessage[]> {
|
|
const conditions: ReturnType<typeof eq>[] = [
|
|
eq(schema.project.chatRoomMessages.roomId, roomId),
|
|
gt(schema.project.chatRoomMessages.createdAt, sinceIso),
|
|
];
|
|
if (options?.excludeSenderAgentId) {
|
|
// (senderAgentId IS NULL OR senderAgentId != ?)
|
|
conditions.push(
|
|
orFn(
|
|
isNull(schema.project.chatRoomMessages.senderAgentId),
|
|
ne(schema.project.chatRoomMessages.senderAgentId, options.excludeSenderAgentId),
|
|
)!,
|
|
);
|
|
}
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatRoomMessages)
|
|
.where(and(...conditions))
|
|
.orderBy(asc(schema.project.chatRoomMessages.createdAt))
|
|
.limit(options?.limit ?? 50);
|
|
return rows.map(rowToRoomMessage);
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:35:
|
|
* Delete a room message by id and bump the parent room's updatedAt.
|
|
* Returns true if deleted, false if not found. Mirrors sync
|
|
* ChatStore.deleteRoomMessage.
|
|
*/
|
|
export async function deleteChatRoomMessage(
|
|
handle: QueryHandle,
|
|
id: string,
|
|
): Promise<boolean> {
|
|
const existing = await getChatRoomMessage(handle, id);
|
|
if (!existing) return false;
|
|
await handle.delete(schema.project.chatRoomMessages).where(eq(schema.project.chatRoomMessages.id, id));
|
|
await handle
|
|
.update(schema.project.chatRooms)
|
|
.set({ updatedAt: new Date().toISOString() })
|
|
.where(eq(schema.project.chatRooms.id, existing.roomId));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:40:
|
|
* Append a file attachment to an existing room message's attachments jsonb
|
|
* array. Bumps the room's updatedAt. Returns the updated message. Throws if
|
|
* the message does not exist in the given room. Mirrors sync
|
|
* ChatStore.addRoomMessageAttachment.
|
|
*/
|
|
export async function addChatRoomMessageAttachment(
|
|
handle: QueryHandle,
|
|
roomId: string,
|
|
messageId: string,
|
|
attachment: ChatAttachment,
|
|
): Promise<ChatRoomMessage> {
|
|
const message = await getChatRoomMessage(handle, messageId);
|
|
if (!message || message.roomId !== roomId) {
|
|
throw new Error(`Message ${messageId} not found in room ${roomId}`);
|
|
}
|
|
const updatedAttachments = [...(message.attachments ?? []), attachment];
|
|
await handle
|
|
.update(schema.project.chatRoomMessages)
|
|
.set({ attachments: updatedAttachments })
|
|
.where(eq(schema.project.chatRoomMessages.id, messageId));
|
|
await handle
|
|
.update(schema.project.chatRooms)
|
|
.set({ updatedAt: new Date().toISOString() })
|
|
.where(eq(schema.project.chatRooms.id, roomId));
|
|
const updated = await getChatRoomMessage(handle, messageId);
|
|
if (!updated) throw new Error(`Failed to update room message ${messageId}`);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ChatStore 2026-06-24-22:45:
|
|
* Find the newest active session for a specific quick-chat target.
|
|
* Matching semantics mirror the sync path:
|
|
* - model target (modelProvider + modelId): exact agent+model match
|
|
* - agent target (no model): prefer model-less sessions, then newest agent
|
|
* session fallback.
|
|
* Returns undefined if no match or the agentId is empty.
|
|
* Mirrors sync ChatStore.findLatestActiveSessionForTarget.
|
|
*/
|
|
export async function findLatestActiveChatSessionForTarget(
|
|
handle: QueryHandle,
|
|
options: {
|
|
agentId: string;
|
|
projectId?: string;
|
|
modelProvider?: string;
|
|
modelId?: string;
|
|
},
|
|
): Promise<ChatSession | undefined> {
|
|
const normalizedAgentId = options.agentId.trim();
|
|
if (!normalizedAgentId) return undefined;
|
|
|
|
const normalizedProvider = options.modelProvider?.trim();
|
|
const normalizedModelId = options.modelId?.trim();
|
|
|
|
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
|
|
throw new Error("modelProvider and modelId must both be provided together, or neither");
|
|
}
|
|
|
|
const baseConditions: ReturnType<typeof eq>[] = [
|
|
eq(schema.project.chatSessions.status, "active"),
|
|
eq(schema.project.chatSessions.agentId, normalizedAgentId),
|
|
];
|
|
if (options.projectId && options.projectId.trim()) {
|
|
baseConditions.push(eq(schema.project.chatSessions.ownerProjectId, options.projectId.trim()));
|
|
}
|
|
|
|
// Model-targeted: exact provider+model match.
|
|
if (normalizedProvider && normalizedModelId) {
|
|
const rows = await handle
|
|
.select()
|
|
.from(schema.project.chatSessions)
|
|
.where(
|
|
and(
|
|
...baseConditions,
|
|
eq(schema.project.chatSessions.modelProvider, normalizedProvider),
|
|
eq(schema.project.chatSessions.modelId, normalizedModelId),
|
|
),
|
|
)
|
|
.orderBy(desc(schema.project.chatSessions.updatedAt))
|
|
.limit(1);
|
|
return rows[0] ? rowToSession(rows[0]) : undefined;
|
|
}
|
|
|
|
// Agent target: prefer model-less sessions first.
|
|
const modelLessRows = await handle
|
|
.select()
|
|
.from(schema.project.chatSessions)
|
|
.where(
|
|
and(
|
|
...baseConditions,
|
|
drizzleSql`COALESCE(TRIM(${schema.project.chatSessions.modelProvider}), '') = ''`,
|
|
drizzleSql`COALESCE(TRIM(${schema.project.chatSessions.modelId}), '') = ''`,
|
|
),
|
|
)
|
|
.orderBy(desc(schema.project.chatSessions.updatedAt))
|
|
.limit(1);
|
|
if (modelLessRows[0]) return rowToSession(modelLessRows[0]);
|
|
|
|
// Fallback: any active session for this agent.
|
|
const fallbackRows = await handle
|
|
.select()
|
|
.from(schema.project.chatSessions)
|
|
.where(and(...baseConditions))
|
|
.orderBy(desc(schema.project.chatSessions.updatedAt))
|
|
.limit(1);
|
|
return fallbackRows[0] ? rowToSession(fallbackRows[0]) : undefined;
|
|
}
|