import {createHash, randomUUID} from "node:crypto"; import {mkdirSync} from "node:fs"; import path from "node:path"; import {Database} from "bun:sqlite"; import {normalizeGenerationSettings, type GenerationSettings} from "./generation-settings"; import type {ChatIdentity} from "./key-vault"; import type { Conversation, ConversationRefState, ConversationSummary, MessageCompletion, MessageOrigin, ResponseMetadata, RepositoryFetch, RepositoryRefUpdate, StoredChatMessage } from "./conversation-types"; import {canonicalMessage} from "./message-object"; const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db"; let database: Database | undefined; type ConversationRow = { id: string; name: string; head_message_id: string | null; provider_id: string; model: string; settings_json: string; head_version: number; metadata_version: number; message_count: number; created_at: string; updated_at: string; }; type MessageRow = { id: string; parent_message_id: string | null; role: StoredChatMessage["role"]; parts_json: string; origin_json: string; completion_json: string; metadata_json: string; depth: number; created_at: string; completed_at: string; }; function now() { return new Date().toISOString(); } function columns(opened: Database, table: string) { return opened.query(`PRAGMA table_info(${table})`).all() as Array<{name: string}>; } function migrateLegacyMessages(opened: Database) { const conversations = opened.query(` SELECT id, owner_issuer, owner_sub FROM chat_conversation WHERE head_message_id IS NULL AND EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id) `).all() as Array<{id: string; owner_issuer: string; owner_sub: string}>; if (!conversations.length) return; opened.run("BEGIN IMMEDIATE"); try { const existingNode = opened.query("SELECT id FROM chat_message_node WHERE id = ?"); const insertNode = opened.query(` INSERT INTO chat_message_node ( id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, depth, created_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const updateHead = opened.query("UPDATE chat_conversation SET head_message_id = ? WHERE id = ?"); for (const conversation of conversations) { const messages = opened.query(` SELECT id, role, parts_json, created_at FROM chat_message WHERE conversation_id = ? ORDER BY ordinal `).all(conversation.id) as Array<{id: string; role: StoredChatMessage["role"]; parts_json: string; created_at: string}>; let parentId: string | null = null; messages.forEach((legacy, depth) => { let id = legacy.id; if (existingNode.get(id)) id = randomUUID(); const parsed = JSON.parse(legacy.parts_json) as StoredChatMessage["parts"]; const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object"); const parts = parsed.filter((part) => part.type !== "data-response-metadata"); const metadata = metadataPart ? {custom: {response: metadataPart.data as ResponseMetadata}} : {}; insertNode.run( id, conversation.owner_issuer, conversation.owner_sub, parentId, legacy.role, JSON.stringify(parts), JSON.stringify({type: "legacy"}), JSON.stringify({status: "complete"}), JSON.stringify(metadata), depth, legacy.created_at, legacy.created_at ); parentId = id; }); updateHead.run(parentId, conversation.id); } opened.run("COMMIT"); } catch (error) { opened.run("ROLLBACK"); throw error; } } function migrateConversationHeadsToContentObjects(opened: Database) { const conversations = opened.query(` SELECT id, owner_issuer, owner_sub, head_message_id FROM chat_conversation WHERE head_message_id IS NOT NULL AND head_message_id NOT LIKE 'sha256:%' `).all() as Array<{id: string; owner_issuer: string; owner_sub: string; head_message_id: string}>; if (!conversations.length) return; opened.run("BEGIN IMMEDIATE"); try { const read = opened.query(` SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, depth, created_at, completed_at FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `); const insert = opened.query(` INSERT OR IGNORE INTO chat_message_node ( id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, depth, created_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const update = opened.query("UPDATE chat_conversation SET head_message_id = ?, head_version = head_version + 1 WHERE id = ?"); for (const conversation of conversations) { const path: MessageRow[] = []; let cursor: string | null = conversation.head_message_id; while (cursor) { const row = read.get(cursor, conversation.owner_issuer, conversation.owner_sub) as MessageRow | undefined; if (!row) throw new Error(`Legacy object ${cursor} is unavailable`); path.push(row); cursor = row.parent_message_id; } path.reverse(); const namespace = createHash("sha256").update(`${conversation.owner_issuer}\0${conversation.owner_sub}`).digest("hex").slice(0, 32); let parentMessageId: string | null = null; for (const row of path) { const parsed = parsedMessage(row); const {id: _legacyId, ...legacyContent} = parsed; const content: Omit = {...legacyContent, parentMessageId}; const id: string = `sha256:${createHash("sha256").update(`${namespace}\0${canonicalMessage(content)}`).digest("hex")}`; insert.run( id, conversation.owner_issuer, conversation.owner_sub, parentMessageId, parsed.role, JSON.stringify(parsed.parts), JSON.stringify(parsed.origin), JSON.stringify(parsed.completion), JSON.stringify(parsed.metadata || {}), row.depth, parsed.createdAt, parsed.completedAt ); parentMessageId = id; } update.run(parentMessageId, conversation.id); } opened.run("COMMIT"); } catch (error) { opened.run("ROLLBACK"); throw error; } } function getDatabase() { if (database) return database; mkdirSync(path.dirname(databasePath), {recursive: true}); const opened = new Database(databasePath, {create: true, strict: true}); opened.run(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; CREATE TABLE IF NOT EXISTS chat_conversation ( id TEXT PRIMARY KEY, owner_issuer TEXT NOT NULL, owner_sub TEXT NOT NULL, title TEXT NOT NULL, provider_id TEXT NOT NULL, model TEXT NOT NULL, settings_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS chat_conversation_owner_updated ON chat_conversation (owner_issuer, owner_sub, updated_at DESC); CREATE TABLE IF NOT EXISTS chat_message ( conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE, id TEXT NOT NULL, ordinal INTEGER NOT NULL, role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')), parts_json TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (conversation_id, id), UNIQUE (conversation_id, ordinal) ); `); const conversationColumns = columns(opened, "chat_conversation"); if (!conversationColumns.some((column) => column.name === "settings_json")) { opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'"); } if (!conversationColumns.some((column) => column.name === "name")) { opened.run("ALTER TABLE chat_conversation ADD COLUMN name TEXT"); opened.run("UPDATE chat_conversation SET name = title WHERE name IS NULL"); } if (!conversationColumns.some((column) => column.name === "head_message_id")) { opened.run("ALTER TABLE chat_conversation ADD COLUMN head_message_id TEXT"); } if (!conversationColumns.some((column) => column.name === "head_version")) { opened.run("ALTER TABLE chat_conversation ADD COLUMN head_version INTEGER NOT NULL DEFAULT 0"); } if (!conversationColumns.some((column) => column.name === "metadata_version")) { opened.run("ALTER TABLE chat_conversation ADD COLUMN metadata_version INTEGER NOT NULL DEFAULT 0"); } opened.run(` CREATE TABLE IF NOT EXISTS chat_message_node ( id TEXT PRIMARY KEY, owner_issuer TEXT NOT NULL, owner_sub TEXT NOT NULL, parent_message_id TEXT REFERENCES chat_message_node(id), role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')), parts_json TEXT NOT NULL, origin_json TEXT NOT NULL, completion_json TEXT NOT NULL, metadata_json TEXT NOT NULL DEFAULT '{}', depth INTEGER NOT NULL, created_at TEXT NOT NULL, completed_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS chat_message_node_owner_parent ON chat_message_node (owner_issuer, owner_sub, parent_message_id); CREATE INDEX IF NOT EXISTS chat_conversation_owner_name ON chat_conversation (owner_issuer, owner_sub, name); `); migrateLegacyMessages(opened); migrateConversationHeadsToContentObjects(opened); database = opened; return opened; } function requiredString(value: unknown, field: string, maximum: number) { if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`); return value.trim().slice(0, maximum); } function conversationName(value: unknown) { if (typeof value !== "string") throw new Error("name must be a string"); return value.trim().slice(0, 300); } function nullableId(value: unknown, field: string) { if (value === null || value === undefined || value === "") return null; if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is invalid`); return value.trim().slice(0, 160); } function conversationSummary(row: ConversationRow): ConversationSummary { return { id: row.id, name: row.name, headMessageId: row.head_message_id, providerId: row.provider_id, model: row.model, messageCount: Number(row.message_count), createdAt: row.created_at, updatedAt: row.updated_at, upstreamHeadMessageId: row.head_message_id, headVersion: Number(row.head_version || 0), metadataVersion: Number(row.metadata_version || 0) }; } const conversationSelect = ` SELECT c.id, COALESCE(c.name, c.title) AS name, c.head_message_id, c.provider_id, c.model, c.head_version, c.metadata_version, c.settings_json, c.created_at, c.updated_at, COALESCE(h.depth + 1, 0) AS message_count FROM chat_conversation c LEFT JOIN chat_message_node h ON h.id = c.head_message_id `; function ownedConversation(identity: ChatIdentity, id: string) { return getDatabase().query(`${conversationSelect} WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ? `).get(id, identity.issuer, identity.sub) as ConversationRow | undefined; } function ownedMessage(identity: ChatIdentity, id: string) { return getDatabase().query(` SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, depth, created_at, completed_at FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `).get(id, identity.issuer, identity.sub) as MessageRow | undefined; } function parsedMessage(row: MessageRow): StoredChatMessage { const metadata = JSON.parse(row.metadata_json || "{}") as StoredChatMessage["metadata"]; return { id: row.id, parentMessageId: row.parent_message_id, role: row.role, parts: JSON.parse(row.parts_json), origin: JSON.parse(row.origin_json), completion: JSON.parse(row.completion_json), createdAt: row.created_at, completedAt: row.completed_at, ...(metadata && Object.keys(metadata).length ? {metadata} : {}) }; } function messagePath(identity: ChatIdentity, headId: string | null) { const reversed: StoredChatMessage[] = []; const seen = new Set(); let id = headId; while (id) { if (seen.has(id) || reversed.length >= 500) throw new Error("Message history is cyclic or too long"); seen.add(id); const row = ownedMessage(identity, id); if (!row) throw new Error("Conversation points to an unavailable message"); reversed.push(parsedMessage(row)); id = row.parent_message_id; } return reversed.reverse(); } function normalizedParts(value: unknown) { if (!Array.isArray(value)) throw new Error("parts is required"); const encoded = JSON.stringify(value); if (Buffer.byteLength(encoded) > 1024 * 1024) throw new Error("message is too large"); return JSON.parse(encoded) as StoredChatMessage["parts"]; } function normalizedOrigin(value: unknown, role: StoredChatMessage["role"]): MessageOrigin { if (value && typeof value === "object" && !Array.isArray(value) && typeof (value as {type?: unknown}).type === "string") { return JSON.parse(JSON.stringify(value)) as MessageOrigin; } if (role === "user") return {type: "user"}; if (role === "system") return {type: "system", source: "chat"}; return {type: "legacy"}; } function normalizedCompletion(value: unknown): MessageCompletion { if (value && typeof value === "object" && !Array.isArray(value) && (value as {status?: unknown}).status === "partial") { return JSON.parse(JSON.stringify(value)) as MessageCompletion; } return {status: "complete"}; } function normalizedMetadata(value: unknown) { const metadata = value && typeof value === "object" && !Array.isArray(value) ? value : {}; const encoded = JSON.stringify(metadata); if (Buffer.byteLength(encoded) > 64 * 1024) throw new Error("message metadata is too large"); return encoded; } export function listConversations(identity: ChatIdentity): ConversationSummary[] { return (getDatabase().query(`${conversationSelect} WHERE c.owner_issuer = ? AND c.owner_sub = ? ORDER BY c.updated_at DESC LIMIT 100 `).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationSummary); } export function createConversation(identity: ChatIdentity, input: { providerId: unknown; model: unknown; generationSettings?: unknown; name?: unknown; headMessageId?: unknown; }): Conversation { const providerId = requiredString(input.providerId, "providerId", 80); const model = requiredString(input.model, "model", 300); const name = input.name === undefined ? "" : conversationName(input.name); const headMessageId = nullableId(input.headMessageId, "headMessageId"); if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("headMessageId is unavailable"); const generationSettings = normalizeGenerationSettings(input.generationSettings); const timestamp = now(); const id = randomUUID(); getDatabase().query(` INSERT INTO chat_conversation ( id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model, settings_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp); return getConversation(identity, id)!; } export function getConversation(identity: ChatIdentity, id: string): Conversation | null { const row = ownedConversation(identity, id); if (!row) return null; return { ...conversationSummary(row), generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), messages: messagePath(identity, row.head_message_id) }; } export function updateConversation(identity: ChatIdentity, id: string, input: { providerId?: unknown; model?: unknown; generationSettings?: unknown; name?: unknown; }) { const existing = ownedConversation(identity, id); if (!existing) return null; const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80); const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300); const name = input.name === undefined ? existing.name : conversationName(input.name); const generationSettings = input.generationSettings === undefined ? normalizeGenerationSettings(JSON.parse(existing.settings_json || "{}")) : normalizeGenerationSettings(input.generationSettings); getDatabase().query(` UPDATE chat_conversation SET title = ?, name = ?, provider_id = ?, model = ?, settings_json = ?, metadata_version = metadata_version + 1, updated_at = ? WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `).run(name, name, providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub); return getConversation(identity, id); } export function appendConversationMessage(identity: ChatIdentity, conversationId: string, input: { id?: unknown; expectedHeadId?: unknown; parentMessageId?: unknown; role?: unknown; parts?: unknown; origin?: unknown; completion?: unknown; metadata?: unknown; providerId?: unknown; model?: unknown; }) { const role = String(input.role || "") as StoredChatMessage["role"]; if (!["system", "user", "assistant"].includes(role)) throw new Error("role is invalid"); const id = input.id === undefined ? randomUUID() : requiredString(input.id, "id", 160); const expectedHeadId = nullableId(input.expectedHeadId, "expectedHeadId"); const parentMessageId = nullableId(input.parentMessageId, "parentMessageId"); const parts = normalizedParts(input.parts); const origin = normalizedOrigin(input.origin, role); const completion = normalizedCompletion(input.completion); const metadataJson = normalizedMetadata(input.metadata); const timestamp = now(); getDatabase().run("BEGIN IMMEDIATE"); try { const existing = ownedConversation(identity, conversationId); if (!existing) { getDatabase().run("ROLLBACK"); return {status: "missing" as const}; } if (existing.head_message_id !== expectedHeadId) { getDatabase().run("ROLLBACK"); return {status: "conflict" as const, conversation: getConversation(identity, conversationId)!}; } const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined; if (parentMessageId && !parent) throw new Error("parentMessageId is unavailable"); const depth = parent ? parent.depth + 1 : 0; const already = ownedMessage(identity, id); if (already) { const same = already.parent_message_id === parentMessageId && already.role === role && already.parts_json === JSON.stringify(parts) && already.origin_json === JSON.stringify(origin) && already.completion_json === JSON.stringify(completion) && already.metadata_json === metadataJson; if (!same) throw new Error("message id already exists with different content"); } else { getDatabase().query(` INSERT INTO chat_message_node ( id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, depth, created_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, identity.issuer, identity.sub, parentMessageId, role, JSON.stringify(parts), JSON.stringify(origin), JSON.stringify(completion), metadataJson, depth, timestamp, timestamp ); } const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80); const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300); const name = existing.name; getDatabase().query(` UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, head_version = head_version + 1, updated_at = ? WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `).run(name, name, id, providerId, model, timestamp, conversationId, identity.issuer, identity.sub); getDatabase().run("COMMIT"); return {status: "ok" as const, conversation: getConversation(identity, conversationId)!}; } catch (error) { try { getDatabase().run("ROLLBACK"); } catch {} throw error; } } function normalizeLegacyMessages(value: unknown): StoredChatMessage[] { if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries"); const timestamp = now(); let parentMessageId: string | null = null; return value.map((message, index) => { if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error(`messages[${index}] is invalid`); const record = message as Record; const role = String(record.role) as StoredChatMessage["role"]; if (!["system", "user", "assistant"].includes(role)) throw new Error(`messages[${index}].role is invalid`); const normalized: StoredChatMessage = { id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(), parentMessageId, role, parts: normalizedParts(record.parts), origin: normalizedOrigin(record.origin, role), completion: normalizedCompletion(record.completion), createdAt: typeof record.createdAt === "string" ? record.createdAt : timestamp, completedAt: typeof record.completedAt === "string" ? record.completedAt : timestamp, ...(record.metadata ? {metadata: JSON.parse(normalizedMetadata(record.metadata))} : {}) }; parentMessageId = normalized.id; return normalized; }); } // Compatibility for pending writes created by the previous client. New code commits one immutable message at a time. export function saveConversationMessages(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; messages: unknown}) { const existing = ownedConversation(identity, id); if (!existing) return null; const messages = normalizeLegacyMessages(input.messages); let expectedHeadId = existing.head_message_id; for (const message of messages) { const already = ownedMessage(identity, message.id); if (already) { expectedHeadId = message.id; continue; } const result = appendConversationMessage(identity, id, { ...message, expectedHeadId, providerId: input.providerId, model: input.model }); if (result.status !== "ok") throw new Error("Unable to import legacy conversation path"); expectedHeadId = message.id; } if (!messages.length && existing.head_message_id) { getDatabase().query(` UPDATE chat_conversation SET head_message_id = NULL, provider_id = ?, model = ?, updated_at = ? WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `).run(requiredString(input.providerId, "providerId", 80), requiredString(input.model, "model", 300), now(), id, identity.issuer, identity.sub); } return getConversation(identity, id); } export function deleteConversation(identity: ChatIdentity, id: string) { const result = getDatabase().query(` DELETE FROM chat_conversation WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `).run(id, identity.issuer, identity.sub); return result.changes > 0; } function conversationRef(row: ConversationRow): ConversationRefState { return { id: row.id, name: row.name, headMessageId: row.head_message_id, providerId: row.provider_id, model: row.model, generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), headVersion: Number(row.head_version || 0), metadataVersion: Number(row.metadata_version || 0), createdAt: row.created_at, updatedAt: row.updated_at }; } export function fetchRepository(identity: ChatIdentity, haveObjectIds: unknown): RepositoryFetch { const have = new Set(Array.isArray(haveObjectIds) ? haveObjectIds.filter((value): value is string => typeof value === "string").slice(0, 100_000) : []); const refs = (getDatabase().query(`${conversationSelect} WHERE c.owner_issuer = ? AND c.owner_sub = ? ORDER BY c.updated_at DESC `).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationRef); const objects = (getDatabase().query(` SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, depth, created_at, completed_at FROM chat_message_node WHERE owner_issuer = ? AND owner_sub = ? ORDER BY depth, created_at, id `).all(identity.issuer, identity.sub) as MessageRow[]) .filter((row) => !have.has(row.id)) .map(parsedMessage); return {refs, objects, fetchedAt: now()}; } export function putRepositoryObjects(identity: ChatIdentity, objects: unknown) { if (!Array.isArray(objects) || objects.length > 1000) throw new Error("objects must contain at most 1000 entries"); let inserted = 0; getDatabase().run("BEGIN IMMEDIATE"); try { const insert = getDatabase().query(` INSERT INTO chat_message_node ( id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json, depth, created_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const value of objects) { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("repository object is invalid"); const object = value as StoredChatMessage; if (typeof object.id !== "string" || !object.id.startsWith("sha256:")) throw new Error("repository object id is invalid"); if (ownedMessage(identity, object.id)) continue; const role = object.role; if (!["system", "user", "assistant"].includes(role)) throw new Error("repository object role is invalid"); const parentMessageId = nullableId(object.parentMessageId, "parentMessageId"); const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined; if (parentMessageId && !parent) throw new Error(`parent object ${parentMessageId} is unavailable`); insert.run( object.id, identity.issuer, identity.sub, parentMessageId, role, JSON.stringify(normalizedParts(object.parts)), JSON.stringify(normalizedOrigin(object.origin, role)), JSON.stringify(normalizedCompletion(object.completion)), normalizedMetadata(object.metadata), parent ? parent.depth + 1 : 0, requiredString(object.createdAt, "createdAt", 80), requiredString(object.completedAt, "completedAt", 80) ); inserted += 1; } getDatabase().run("COMMIT"); } catch (error) { try { getDatabase().run("ROLLBACK"); } catch {} throw error; } return inserted; } export function pushRepositoryRef(identity: ChatIdentity, update: RepositoryRefUpdate) { const id = requiredString(update.conversationId, "conversationId", 160); const expectedHeadMessageId = nullableId(update.expectedHeadMessageId, "expectedHeadMessageId"); const headMessageId = nullableId(update.headMessageId, "headMessageId"); const existing = ownedConversation(identity, id); if (!existing) { if (expectedHeadMessageId !== null || Number(update.expectedHeadVersion || 0) !== 0 || Number(update.expectedMetadataVersion || 0) !== 0) { return {status: "conflict" as const, ref: null}; } if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable"); const name = conversationName(update.name); const providerId = requiredString(update.providerId, "providerId", 80); const model = requiredString(update.model, "model", 300); const timestamp = now(); getDatabase().query(` INSERT INTO chat_conversation ( id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model, settings_json, head_version, metadata_version, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?) `).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(normalizeGenerationSettings(update.generationSettings)), update.createdAt || timestamp, timestamp); return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)}; } if (existing.head_message_id !== expectedHeadMessageId || Number(existing.head_version || 0) !== Number(update.expectedHeadVersion || 0) || Number(existing.metadata_version || 0) !== Number(update.expectedMetadataVersion || 0)) { return {status: "conflict" as const, ref: conversationRef(existing)}; } if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable"); const name = conversationName(update.name); const providerId = requiredString(update.providerId, "providerId", 80); const model = requiredString(update.model, "model", 300); const headChanged = existing.head_message_id !== headMessageId; const metadataChanged = existing.name !== name || existing.provider_id !== providerId || existing.model !== model || existing.settings_json !== JSON.stringify(normalizeGenerationSettings(update.generationSettings)); getDatabase().query(` UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, settings_json = ?, head_version = head_version + ?, metadata_version = metadata_version + ?, updated_at = ? WHERE id = ? AND owner_issuer = ? AND owner_sub = ? `).run( name, name, headMessageId, providerId, model, JSON.stringify(normalizeGenerationSettings(update.generationSettings)), headChanged ? 1 : 0, metadataChanged ? 1 : 0, now(), id, identity.issuer, identity.sub ); return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)}; }