Files
freya/apps/freya-backend/src/conversations/storage.ts

390 lines
11 KiB
TypeScript
Raw Normal View History

import {
2026-06-16 20:16:03 +01:00
AssistantMessagePayload,
AttachmentPayload,
ConversationEntryKind,
ConversationEntryVisibility,
2026-06-16 20:16:03 +01:00
ContextSummaryPayload,
ConversationEntryMetadata,
GenericObjectPayload,
UserMessagePayload,
type ConversationEntryPayload,
} from "@freya/core"
import { type } from "arktype"
import { and, asc, desc, eq } from "drizzle-orm"
import type { Database } from "../db/index.ts"
2026-06-16 20:16:03 +01:00
import {
conversationEntries,
conversations as conversationsTable,
files,
user,
} from "../db/schema.ts"
import { ConversationNotFoundError } from "./errors.ts"
2026-06-16 20:16:03 +01:00
const conversationEntryKind = type.enumerated(...Object.values(ConversationEntryKind))
const conversationEntryVisibility = type.enumerated(...Object.values(ConversationEntryVisibility))
/** Database row shape for a conversation owned by a user. */
2026-06-16 20:16:03 +01:00
export type ConversationRow = typeof conversationsTable.$inferSelect
/** Database row shape for an entry in a conversation timeline. */
2026-06-16 20:16:03 +01:00
export type ConversationEntryRow = typeof conversationEntries.$inferSelect
/** Database row shape for an uploaded file referenced by conversations. */
2026-06-16 20:16:03 +01:00
export type FileRow = typeof files.$inferSelect
/** Input required to create a stored file record. */
2026-06-16 20:16:03 +01:00
export interface CreateFileInput {
storageKey: string
originalName?: string
mimeType: string
sizeBytes: number
metadata?: Record<string, unknown>
}
/** Input for creating a file and appending its attachment entry together. */
2026-06-16 20:16:03 +01:00
export interface AppendAttachmentEntryInput {
file: CreateFileInput
payload: AttachmentPayload
visibility?: ConversationEntryVisibility
2026-06-16 20:16:03 +01:00
metadata?: ConversationEntryMetadata
}
/** Result returned after a file-backed attachment entry is appended. */
2026-06-16 20:16:03 +01:00
export interface AppendAttachmentEntryResult {
file: FileRow
entry: ConversationEntryRow
}
/** Common fields accepted when appending any conversation entry. */
2026-06-16 20:16:03 +01:00
interface AppendConversationEntryBase {
visibility?: ConversationEntryVisibility
2026-06-16 20:16:03 +01:00
metadata?: ConversationEntryMetadata
}
/** Discriminated input for appending any supported entry kind to a conversation. */
2026-06-16 20:16:03 +01:00
export type AppendConversationEntryInput =
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.UserMessage
payload: UserMessagePayload
fileId?: never
})
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.AssistantMessage
payload: AssistantMessagePayload
fileId?: never
})
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.Attachment
payload: AttachmentPayload
fileId: string
})
| (AppendConversationEntryBase & {
kind: typeof ConversationEntryKind.ContextSummary
payload: ContextSummaryPayload
fileId?: never
})
| (AppendConversationEntryBase & {
kind:
| typeof ConversationEntryKind.ToolCall
| typeof ConversationEntryKind.ToolResult
| typeof ConversationEntryKind.SystemNote
payload: GenericObjectPayload
fileId?: never
})
/** Filters accepted when listing conversation entries. */
2026-06-16 20:16:03 +01:00
export interface ListConversationEntriesParams {
visibility?: ConversationEntryVisibility
2026-06-16 20:16:03 +01:00
}
export function conversations(db: Database, userId: string) {
const storage = {
2026-06-16 20:16:03 +01:00
async createConversation(): Promise<ConversationRow> {
return insertConversation(db, userId)
},
async listConversations(): Promise<ConversationRow[]> {
return db
.select()
.from(conversationsTable)
.where(eq(conversationsTable.userId, userId))
.orderBy(desc(conversationsTable.updatedAt), desc(conversationsTable.createdAt))
},
async getConversation(conversationId: string): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
.where(
and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)),
)
.limit(1)
return rows[0] ?? null
},
2026-06-16 20:16:03 +01:00
async getOrCreateConversation(): Promise<ConversationRow> {
return db.transaction(async (tx) => {
await requireUserForUpdate(tx, userId)
const existing = await latestConversation(tx, userId)
if (existing) return existing
return insertConversation(tx, userId)
})
},
async createFile(input: CreateFileInput): Promise<FileRow> {
return insertFile(db, userId, input)
},
async appendEntry(
conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationEntryRow> {
const kind = conversationEntryKind.assert(input.kind)
const visibility = conversationEntryVisibility.assert(
2026-06-16 20:16:03 +01:00
input.visibility ?? defaultVisibilityForKind(kind),
)
const payload = payloadForKind(kind, input.payload)
const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
2026-06-16 20:16:03 +01:00
let fileId: string | null = null
if (input.kind === ConversationEntryKind.Attachment) {
fileId = input.fileId
await requireFile(db, userId, fileId)
}
const rows = await db.transaction(async (tx) => {
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
2026-06-16 20:16:03 +01:00
const sequence = await nextSequence(tx, conversationId)
const rows = await tx
.insert(conversationEntries)
.values({
conversationId,
sequence,
kind,
visibility,
fileId,
payload,
metadata,
})
.returning()
await touchConversation(tx, userId, conversationId)
return rows
})
return requireRow(rows)
},
async appendAttachmentEntry(
conversationId: string,
input: AppendAttachmentEntryInput,
): Promise<AppendAttachmentEntryResult> {
const payload = AttachmentPayload.assert(input.payload)
const visibility = conversationEntryVisibility.assert(
2026-06-16 20:16:03 +01:00
input.visibility ?? defaultVisibilityForKind(ConversationEntryKind.Attachment),
)
const metadata = ConversationEntryMetadata.assert(input.metadata ?? {})
2026-06-16 20:16:03 +01:00
return db.transaction(async (tx) => {
if (!(await findConversationForUpdate(tx, userId, conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
2026-06-16 20:16:03 +01:00
const file = await insertFile(tx, userId, input.file)
const sequence = await nextSequence(tx, conversationId)
const rows = await tx
.insert(conversationEntries)
.values({
conversationId,
sequence,
kind: ConversationEntryKind.Attachment,
visibility,
fileId: file.id,
payload,
metadata,
})
.returning()
await touchConversation(tx, userId, conversationId)
return {
file,
entry: requireRow(rows),
}
})
},
async listEntries(
conversationId: string,
params: ListConversationEntriesParams = {},
): Promise<ConversationEntryRow[]> {
if (!(await storage.getConversation(conversationId))) {
throw new ConversationNotFoundError(conversationId, userId)
}
2026-06-16 20:16:03 +01:00
if (params.visibility) {
return db
.select()
.from(conversationEntries)
.where(
and(
eq(conversationEntries.conversationId, conversationId),
eq(conversationEntries.visibility, params.visibility),
),
)
.orderBy(asc(conversationEntries.sequence))
}
return db
.select()
.from(conversationEntries)
.where(eq(conversationEntries.conversationId, conversationId))
.orderBy(asc(conversationEntries.sequence))
},
}
return storage
2026-06-16 20:16:03 +01:00
}
function payloadForKind(
kind: ConversationEntryKind,
2026-06-16 20:16:03 +01:00
payload: AppendConversationEntryInput["payload"],
): ConversationEntryPayload {
switch (kind) {
case ConversationEntryKind.UserMessage:
return UserMessagePayload.assert(payload)
2026-06-16 20:16:03 +01:00
case ConversationEntryKind.AssistantMessage:
return AssistantMessagePayload.assert(payload)
2026-06-16 20:16:03 +01:00
case ConversationEntryKind.Attachment:
return AttachmentPayload.assert(payload)
2026-06-16 20:16:03 +01:00
case ConversationEntryKind.ContextSummary:
return ContextSummaryPayload.assert(payload)
2026-06-16 20:16:03 +01:00
case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult:
case ConversationEntryKind.SystemNote:
return GenericObjectPayload.assert(payload)
2026-06-16 20:16:03 +01:00
}
}
async function requireUserForUpdate(db: Database, userId: string): Promise<void> {
const rows = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, userId))
.limit(1)
.for("update")
requireRow(rows, `User not found: ${userId}`)
}
async function findConversationForUpdate(
2026-06-16 20:16:03 +01:00
db: Database,
userId: string,
conversationId: string,
): Promise<ConversationRow | null> {
2026-06-16 20:16:03 +01:00
const rows = await db
.select()
.from(conversationsTable)
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
.limit(1)
.for("update")
return rows[0] ?? null
2026-06-16 20:16:03 +01:00
}
async function latestConversation(db: Database, userId: string): Promise<ConversationRow | null> {
const rows = await db
.select()
.from(conversationsTable)
.where(eq(conversationsTable.userId, userId))
.orderBy(desc(conversationsTable.updatedAt), desc(conversationsTable.createdAt))
.limit(1)
return rows[0] ?? null
}
async function insertConversation(db: Database, userId: string): Promise<ConversationRow> {
const rows = await db
.insert(conversationsTable)
.values({
userId,
})
.returning()
return requireRow(rows)
}
async function requireFile(db: Database, userId: string, fileId: string): Promise<FileRow> {
const rows = await db
.select()
.from(files)
.where(and(eq(files.id, fileId), eq(files.userId, userId)))
.limit(1)
return requireRow(rows, `File not found: ${fileId}`)
}
async function insertFile(db: Database, userId: string, input: CreateFileInput): Promise<FileRow> {
const rows = await db
.insert(files)
.values({
userId,
storageKey: input.storageKey,
originalName: input.originalName ?? null,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
metadata: input.metadata ?? {},
})
.returning()
return requireRow(rows)
}
async function touchConversation(
db: Database,
userId: string,
conversationId: string,
): Promise<void> {
await db
.update(conversationsTable)
.set({ updatedAt: new Date() })
.where(and(eq(conversationsTable.id, conversationId), eq(conversationsTable.userId, userId)))
}
async function nextSequence(db: Database, conversationId: string): Promise<number> {
const rows = await db
.select({ sequence: conversationEntries.sequence })
.from(conversationEntries)
.where(eq(conversationEntries.conversationId, conversationId))
.orderBy(desc(conversationEntries.sequence))
.limit(1)
return (rows[0]?.sequence ?? 0) + 1
}
function requireRow<T>(rows: T[], message = "Expected database row"): T {
const row = rows[0]
if (!row) throw new Error(message)
return row
}
function defaultVisibilityForKind(kind: ConversationEntryKind): ConversationEntryVisibility {
2026-06-16 20:16:03 +01:00
switch (kind) {
case ConversationEntryKind.UserMessage:
case ConversationEntryKind.AssistantMessage:
case ConversationEntryKind.Attachment:
return ConversationEntryVisibility.UserVisible
case ConversationEntryKind.ToolCall:
case ConversationEntryKind.ToolResult:
case ConversationEntryKind.ContextSummary:
case ConversationEntryKind.SystemNote:
return ConversationEntryVisibility.Internal
}
}