Files
homelab/chat/lib/conversations.ts

706 lines
30 KiB
TypeScript

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<StoredChatMessage, "id"> = {...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<string>();
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<string, unknown>;
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)!)};
}