rewritten 3/4 db files and config to ts
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
export function initializeAttachments(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
original_name TEXT NOT NULL, storage_key TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'temporary' CHECK(status IN ('temporary','attached')),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
vision_model TEXT, vision_analysis TEXT, vision_analyzed_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS message_attachments (
|
||||
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(message_id, attachment_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS attachment_references ON message_attachments(attachment_id);
|
||||
CREATE TABLE IF NOT EXISTS attachment_vision_analyses (
|
||||
attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
|
||||
vision_model TEXT NOT NULL,
|
||||
prompt_version INTEGER NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
analysis TEXT NOT NULL,
|
||||
analyzed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (attachment_id, vision_model, prompt_version, question)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS upload_deletions (storage_key TEXT PRIMARY KEY);
|
||||
CREATE TRIGGER IF NOT EXISTS release_message_attachment AFTER DELETE ON message_attachments
|
||||
BEGIN
|
||||
DELETE FROM attachments WHERE id = OLD.attachment_id AND status = 'attached'
|
||||
AND NOT EXISTS (SELECT 1 FROM message_attachments WHERE attachment_id = OLD.attachment_id);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS queue_attachment_file_delete AFTER DELETE ON attachments
|
||||
BEGIN INSERT OR IGNORE INTO upload_deletions(storage_key) VALUES(OLD.storage_key); END;
|
||||
`);
|
||||
}
|
||||
|
||||
export const attachmentMetadata = (row) => ({
|
||||
id: row.id, type: "image", originalName: row.original_name, mimeType: row.mime_type,
|
||||
sizeBytes: row.size_bytes, width: row.width, height: row.height
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
/* Interface für row */
|
||||
interface AttachmentRow {
|
||||
id: string,
|
||||
original_name: string,
|
||||
mime_type: string,
|
||||
size_bytes: number,
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
|
||||
/* Interface für Rückgabe der Metadaten */
|
||||
interface AttachmentMetadata {
|
||||
id: string,
|
||||
type: "image",
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
sizeBytes: number,
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt alle Tabellen, Indizes und Trigger für Bild-Anhänge an.
|
||||
*
|
||||
* Betroffene Tabellen: `attachments`, `message_attachments`,
|
||||
* `attachment_vision_analyses` und `upload_deletions`.
|
||||
* Die Funktion kann beliebig oft aufgerufen werden (`IF NOT EXISTS`).
|
||||
*
|
||||
* Voraussetzung: Die Tabellen `users` und `chat_messages` existieren bereits,
|
||||
* da sie per Fremdschlüssel referenziert werden.
|
||||
*
|
||||
* Datenbankbeziehungen und Lebenszyklus eines Anhangs siehe Wiki:
|
||||
* https://git.bachmannj.ch/Fretux/KrisHD_AI/wiki/Datenbank-Schema-(KI-überprüfung-nötig)
|
||||
*
|
||||
* @param db - Offene Datenbankverbindung
|
||||
*/
|
||||
export function initializeAttachments(db: Database.Database): void {
|
||||
db.exec(/* sql */ `
|
||||
-- Metadaten hochgeladener Bilder (die Datei selbst liegt im Upload-Verzeichnis)
|
||||
CREATE TABLE IF NOT EXISTS attachments
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
original_name TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'temporary' CHECK (status IN ('temporary', 'attached')),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
vision_model TEXT,
|
||||
vision_analysis TEXT,
|
||||
vision_analyzed_at TEXT
|
||||
);
|
||||
|
||||
-- Verknüpfung Nachricht <-> Anhang (n:m)
|
||||
CREATE TABLE IF NOT EXISTS message_attachments
|
||||
(
|
||||
message_id INTEGER NOT NULL REFERENCES chat_messages (id) ON DELETE CASCADE,
|
||||
attachment_id TEXT NOT NULL REFERENCES attachments (id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (message_id, attachment_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS attachment_references ON message_attachments (attachment_id);
|
||||
|
||||
-- Zwischengespeicherte Bildanalysen pro Modell, Prompt-Version und Frage
|
||||
CREATE TABLE IF NOT EXISTS attachment_vision_analyses
|
||||
(
|
||||
attachment_id TEXT NOT NULL REFERENCES attachments (id) ON DELETE CASCADE,
|
||||
vision_model TEXT NOT NULL,
|
||||
prompt_version INTEGER NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
analysis TEXT NOT NULL,
|
||||
analyzed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (attachment_id, vision_model, prompt_version, question)
|
||||
);
|
||||
|
||||
-- Warteschlange: Dateien, die die App vom Speicher löschen muss
|
||||
CREATE TABLE IF NOT EXISTS upload_deletions
|
||||
(
|
||||
storage_key TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
-- Löscht einen Anhang, sobald keine Nachricht mehr auf ihn verweist
|
||||
CREATE TRIGGER IF NOT EXISTS release_message_attachment
|
||||
AFTER DELETE ON message_attachments
|
||||
BEGIN
|
||||
DELETE FROM attachments
|
||||
WHERE id = OLD.attachment_id
|
||||
AND status = 'attached'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM message_attachments
|
||||
WHERE attachment_id = OLD.attachment_id
|
||||
);
|
||||
END;
|
||||
|
||||
-- Merkt die Datei eines gelöschten Anhangs zum Löschen vor
|
||||
CREATE TRIGGER IF NOT EXISTS queue_attachment_file_delete
|
||||
AFTER DELETE ON attachments
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO upload_deletions (storage_key)
|
||||
VALUES (OLD.storage_key);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt eine Zeile aus der Tabelle `attachments` in das Format um,
|
||||
* das an das Frontend gesendet wird (camelCase statt snake_case).
|
||||
*
|
||||
* Enthält nur öffentliche Metadaten, keine internen Felder wie `storage_key`.
|
||||
*
|
||||
* @param row - Eine Zeile aus der Tabelle `attachments`
|
||||
* @returns Metadaten des Anhangs für die API
|
||||
*/
|
||||
export const attachmentMetadata = (row: AttachmentRow): AttachmentMetadata => ({
|
||||
id: row.id,
|
||||
type: "image",
|
||||
originalName: row.original_name,
|
||||
mimeType: row.mime_type,
|
||||
sizeBytes: row.size_bytes,
|
||||
width: row.width,
|
||||
height: row.height
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import fs from "fs";
|
||||
import Database from "better-sqlite3";
|
||||
import {initializeSchema, runMigrations} from "./schema.js";
|
||||
import {initializeAttachments} from "./attachments.js";
|
||||
|
||||
export function createDatabase(config) {
|
||||
if (config.dbPath !== ":memory:") {
|
||||
fs.mkdirSync(config.dataDir, {recursive: true});
|
||||
}
|
||||
|
||||
const db = new Database(config.dbPath);
|
||||
initializeSchema(db);
|
||||
runMigrations(db, config);
|
||||
initializeAttachments(db);
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { initializeSchema, runMigrations } from "./schema.ts";
|
||||
import { initializeAttachments } from "./attachments.ts";
|
||||
import type { Config } from "../config.ts";
|
||||
|
||||
/**
|
||||
* Öffnet die Datenbank und legt Schema, Migrationen und Anhang-Tabellen an.
|
||||
*
|
||||
* @param config - Anwendungskonfiguration (siehe `config.ts`).
|
||||
* @returns Die geöffnete Datenbankverbindung.
|
||||
*/
|
||||
export function createDatabase(config: Config): Database.Database {
|
||||
if (config.dbPath !== ":memory:") {
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(config.dbPath);
|
||||
initializeSchema(db);
|
||||
runMigrations(db, config);
|
||||
initializeAttachments(db);
|
||||
return db;
|
||||
}
|
||||
+39
-7
@@ -1,4 +1,36 @@
|
||||
import {attachmentMetadata} from "./attachments.js";
|
||||
import { attachmentMetadata } from "./attachments.ts";
|
||||
|
||||
/*
|
||||
* TODO: Aufräumen und TypeScript-Migration von repositories.js
|
||||
*
|
||||
* Diese Datei ist die Datenzugriffsschicht (Repository-Pattern): Alle SQL-Abfragen
|
||||
* der App liegen hier. Vor der Umstellung auf TypeScript zuerst aufräumen.
|
||||
*
|
||||
* 1. In Dateien pro Bereich aufteilen, z. B. unter src/db/repositories/:
|
||||
* users, chats, messages, memories (inkl. persona_chat_state), personas,
|
||||
* market, templates, dashboard/workspace (Import/Export).
|
||||
* Danach einzeln nach TS migrieren, mit der kleinsten Datei anfangen (templates).
|
||||
*
|
||||
* 2. Doppelten SELECT für chat_sessions zusammenfassen.
|
||||
* listChatSessions, getChatSession, getChatSessionByParticipants und searchChats
|
||||
* haben fast denselben Spaltenblock. In eine Konstante auslagern, damit neue
|
||||
* Spalten nur an einer Stelle ergänzt werden müssen.
|
||||
*
|
||||
* 3. Alle db.prepare() in `statements` verschieben.
|
||||
* Betrifft: listMessageVersions, saveMessageVersion, exportWorkspace und die
|
||||
* Schleifen in importWorkspaceTransaction. Dort wird die Abfrage bei jedem Aufruf
|
||||
* (beim Import sogar pro Nachricht) neu vorbereitet.
|
||||
*
|
||||
* 4. parseJsonArray korrigieren: Parameter `string | null`, Rückgabe `unknown[]`
|
||||
* (sources enthält Objekte, nicht nur Strings).
|
||||
*
|
||||
* 5. Beim Typisieren pro Tabelle ein Row-Interface anlegen (wie AttachmentRow)
|
||||
* und bei db.prepare<Params, Row>() angeben.
|
||||
*
|
||||
* WICHTIG: deleteUserTransaction und renameUserTransaction löschen bzw. benennen
|
||||
* Benutzerdaten von Hand um, weil viele Tabellen keinen Fremdschlüssel auf users
|
||||
* haben. Neue Tabellen mit einer username-Spalte hier ebenfalls eintragen!
|
||||
*/
|
||||
|
||||
const parseJsonArray = (value) => {
|
||||
if (!value) return [];
|
||||
@@ -809,7 +841,7 @@ export function createRepositories(db, config) {
|
||||
});
|
||||
|
||||
const importWorkspaceTransaction = db.transaction((username, workspace) => {
|
||||
const imported = {personas: 0, chats: 0, templates: 0};
|
||||
const imported = { personas: 0, chats: 0, templates: 0 };
|
||||
for (const persona of workspace.personas || []) {
|
||||
const personaId = statements.insertPersona.run(
|
||||
username,
|
||||
@@ -980,7 +1012,7 @@ export function createRepositories(db, config) {
|
||||
);
|
||||
return statements.getPersonaChatState.get(chat.id, username);
|
||||
},
|
||||
updateChatOrganization(chatId, username, {folderName = null, isPinned = false, archivedAt = null}) {
|
||||
updateChatOrganization(chatId, username, { folderName = null, isPinned = false, archivedAt = null }) {
|
||||
return statements.updateChatSessionOrganization.run(folderName, isPinned ? 1 : 0, archivedAt, chatId, username);
|
||||
},
|
||||
touchChat: (chatId, username) => statements.touchChatSession.run(chatId, username),
|
||||
@@ -1151,7 +1183,7 @@ export function createRepositories(db, config) {
|
||||
getActiveUserPersona: (username) => statements.getActiveUserPersona.get(username),
|
||||
getAssistantPersonaForChat: (chatId, username) => statements.getAssistantPersonaForChat.get(chatId, username),
|
||||
getUserPersonaForChat: (chatId, username) => statements.getUserPersonaForChat.get(chatId, username),
|
||||
listMarketPersonas({admin = false, username = null, personaType = null, sort = "best"} = {}) {
|
||||
listMarketPersonas({ admin = false, username = null, personaType = null, sort = "best" } = {}) {
|
||||
const normalizedPersonaType = normalizeMarketPersonaType(personaType);
|
||||
const normalizedSort = normalizeMarketSort(sort);
|
||||
const params = [username];
|
||||
@@ -1188,11 +1220,11 @@ export function createRepositories(db, config) {
|
||||
},
|
||||
getMarketPersona: (marketId) => {
|
||||
const persona = statements.getMarketPersona.get(marketId);
|
||||
return persona ? {...persona, tags: parseJsonArray(persona.tags)} : null;
|
||||
return persona ? { ...persona, tags: parseJsonArray(persona.tags) } : null;
|
||||
},
|
||||
getMarketPersonaByPersonaId: (personaId, username) => {
|
||||
const persona = statements.getMarketPersonaByPersonaId.get(personaId, username);
|
||||
return persona ? {...persona, tags: parseJsonArray(persona.tags)} : null;
|
||||
return persona ? { ...persona, tags: parseJsonArray(persona.tags) } : null;
|
||||
},
|
||||
upsertMarketPersona(personaId, username, persona, tags = []) {
|
||||
statements.upsertMarketPersona.run(
|
||||
@@ -1244,7 +1276,7 @@ export function createRepositories(db, config) {
|
||||
reportMarketPersona: (marketId, username, reason, details) => statements.insertMarketReport.run(marketId, username, reason, details),
|
||||
listMarketReports: () => statements.listMarketReports.all(),
|
||||
updateMarketReportStatus: (reportId, status) => statements.updateMarketReportStatus.run(status, reportId),
|
||||
updateMarketModeration: (marketId, {status, notes = null, softDelete = false}) => statements.updateMarketModeration.run(status, notes, softDelete ? 1 : 0, marketId),
|
||||
updateMarketModeration: (marketId, { status, notes = null, softDelete = false }) => statements.updateMarketModeration.run(status, notes, softDelete ? 1 : 0, marketId),
|
||||
deletePublishedPersona: (personaId, username) => statements.deleteMarketPersonaByPersonaId.run(personaId, username),
|
||||
createPromptTemplate(username, payload) {
|
||||
const id = statements.insertPromptTemplate.run(
|
||||
|
||||
+164
-136
@@ -1,7 +1,82 @@
|
||||
export function initializeSchema(db) {
|
||||
import type Database from "better-sqlite3";
|
||||
/*
|
||||
* TODO: Offene Punkte in Schema und Migrationen
|
||||
*
|
||||
* 1. Fehlendes in initializeSchema ergänzen.
|
||||
* Tabelle chat_message_versions und die Spalten chat_messages.sources und
|
||||
* chat_messages.thinking existieren nur in runMigrations. Nach initializeSchema
|
||||
* verschieben, damit neue Datenbanken nicht von den Migrationen abhängen.
|
||||
*
|
||||
* 2. Doppelte CREATE TABLE-Blöcke am Ende von runMigrations entfernen.
|
||||
* Sie sind identisch zu initializeSchema. Vorher prüfen, ob runMigrations
|
||||
* irgendwo (z. B. in Tests) ohne vorheriges initializeSchema aufgerufen wird.
|
||||
*
|
||||
* 3. Versionierte Migrationen mit PRAGMA user_version einführen.
|
||||
* Jede Migration läuft dann genau einmal. Danach können die ensureColumn-Aufrufe
|
||||
* und die UPDATE ... WHERE ... IS NULL-Zeilen (Full Table Scan bei jedem Start)
|
||||
* in eine einmalige Migration wandern.
|
||||
*
|
||||
* 4. Fehlende Fremdschlüssel auf users prüfen:
|
||||
* chat_sessions, personas, prompt_templates, persona_versions, persona_market,
|
||||
* persona_market_favorites/_ratings/_reports.
|
||||
* Aktuell erledigt deleteUserTransaction in repositories.js das Aufräumen von Hand.
|
||||
* Achtung: SQLite kann Fremdschlüssel nicht per ALTER TABLE hinzufügen,
|
||||
* dafür muss die Tabelle neu erstellt und die Daten kopiert werden.
|
||||
*
|
||||
* 5. Kleinigkeiten:
|
||||
* - ensureColumn: Parameter tableName wird nicht benutzt.
|
||||
* - persona_market_ratings.rating hat keinen CHECK auf den gültigen Wertebereich.
|
||||
* - Zeitstempel uneinheitlich (DATETIME hier, TEXT in attachments.ts).
|
||||
* - attachments.vision_* vs. Tabelle attachment_vision_analyses:
|
||||
* prüfen, ob die Spalten noch gebraucht werden.
|
||||
*
|
||||
* Doku und Beziehungsdiagramm: Wiki-Seite "Datenbank-Schema".
|
||||
*/
|
||||
|
||||
/** Eine Zeile aus `PRAGMA table_info(...)`: Beschreibung einer Spalte. */
|
||||
interface ColumnInfo {
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
}
|
||||
|
||||
/** Optionen für {@link runMigrations}. */
|
||||
export interface MigrationOptions {
|
||||
/** Alte Tabelle `chats` nach der Übernahme löschen. Default: `true`. */
|
||||
dropLegacyChats?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Datenbank-Einstellungen und legt alle Grundtabellen an.
|
||||
*
|
||||
* Bestehende Tabellen werden nicht verändert (`IF NOT EXISTS`).
|
||||
* Fehlende Spalten in älteren Datenbanken ergänzt {@link runMigrations}.
|
||||
*
|
||||
* @param db - Offene Datenbankverbindung
|
||||
*/
|
||||
export function initializeSchema(db: Database.Database): void {
|
||||
// WAL: Lesen bleibt möglich, während geschrieben wird (sqlite spezifisch)
|
||||
db.pragma("journal_mode = WAL");
|
||||
// SQLite prüft Fremdschlüssel nur mit dieser Einstellung (gilt pro Verbindung)
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
// Registrierte Benutzer
|
||||
// Chats (Unterhaltungen) inkl. Modell-Einstellungen und Branch-Informationen
|
||||
// Nachrichten eines Chats, als Baum über parent_message_id verknüpft
|
||||
// Fakten, die sich das Modell pro Chat merkt
|
||||
// Rollenspiel-Zustand eines Chats (1:1 zu chat_sessions)
|
||||
// Personas des Benutzers (für den Assistenten oder den Benutzer selbst)
|
||||
// Einstellungen pro Benutzer (1:1 zu users)
|
||||
// Öffentlich geteilte Personas im Marktplatz
|
||||
// Versionsverlauf einer Persona
|
||||
// Favoriten im Marktplatz (ein Eintrag pro Benutzer und Persona)
|
||||
// Bewertungen im Marktplatz (eine pro Benutzer und Persona)
|
||||
// Meldungen zur Moderation im Marktplatz
|
||||
// Wiederverwendbare Prompt-Vorlagen
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
@@ -235,38 +310,71 @@ export function initializeSchema(db) {
|
||||
`).run();
|
||||
}
|
||||
|
||||
export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS chat_message_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
thinking TEXT,
|
||||
sources TEXT,
|
||||
model_name TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_message_versions_message ON chat_message_versions(message_id);`);
|
||||
const ensureColumn = (tableName, columns, name, sql) => {
|
||||
/**
|
||||
* Bringt eine bestehende Datenbank auf den aktuellen Stand.
|
||||
*
|
||||
* Ergänzt fehlende Tabellen und Spalten aus älteren Versionen der App und
|
||||
* übernimmt Daten aus der alten Tabelle `chats`. Läuft bei jedem Start, ändert
|
||||
* aber nur, was noch fehlt (idempotent).
|
||||
*
|
||||
* Muss nach {@link initializeSchema} aufgerufen werden.
|
||||
*
|
||||
* @param db - Offene Datenbankverbindung
|
||||
* @param options - Siehe {@link MigrationOptions}
|
||||
*/
|
||||
export function runMigrations(
|
||||
db: Database.Database,
|
||||
{ dropLegacyChats = true }: MigrationOptions = {}
|
||||
): void {
|
||||
// Versionsverlauf von Nachrichten.
|
||||
// Existiert nur hier, wird also auch bei neuen Datenbanken hier angelegt.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_message_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
thinking TEXT,
|
||||
sources TEXT,
|
||||
model_name TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_message_versions_message ON chat_message_versions(message_id);
|
||||
`);
|
||||
|
||||
/**
|
||||
* Liest die vorhandenen Spalten einer Tabelle.
|
||||
* Der Tabellenname wird direkt eingesetzt, da PRAGMA keine Parameter (?) erlaubt.
|
||||
* Deshalb nur mit festen Tabellennamen aus dem Code aufrufen, nie mit Benutzereingaben.
|
||||
*/
|
||||
const getColumns = (tableName: string): ColumnInfo[] =>
|
||||
db.prepare<[], ColumnInfo>(`PRAGMA table_info(${tableName})`).all();
|
||||
|
||||
/** Führt `sql` aus, wenn die Spalte `name` in `columns` noch fehlt. */
|
||||
const ensureColumn = (tableName: string, columns: ColumnInfo[], name: string, sql: string): void => {
|
||||
if (!columns.some((column) => column.name === name)) {
|
||||
db.prepare(sql).run();
|
||||
}
|
||||
};
|
||||
|
||||
const userColumns = db.prepare("PRAGMA table_info(users)").all();
|
||||
// --- users ---
|
||||
const userColumns = getColumns("users");
|
||||
ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0");
|
||||
db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run();
|
||||
|
||||
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
|
||||
// --- personas ---
|
||||
const personaColumns = getColumns("personas");
|
||||
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
|
||||
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
|
||||
ensureColumn("personas", personaColumns, "source_market_id", "ALTER TABLE personas ADD COLUMN source_market_id INTEGER");
|
||||
ensureColumn("personas", personaColumns, "source_creator_username", "ALTER TABLE personas ADD COLUMN source_creator_username TEXT");
|
||||
ensureColumn("personas", personaColumns, "example_dialogues", "ALTER TABLE personas ADD COLUMN example_dialogues TEXT");
|
||||
|
||||
const userSettingsColumns = db.prepare("PRAGMA table_info(user_settings)").all();
|
||||
// --- user_settings ---
|
||||
const userSettingsColumns = getColumns("user_settings");
|
||||
ensureColumn("user_settings", userSettingsColumns, "active_user_persona_id", "ALTER TABLE user_settings ADD COLUMN active_user_persona_id INTEGER");
|
||||
|
||||
const marketColumns = db.prepare("PRAGMA table_info(persona_market)").all();
|
||||
// --- persona_market ---
|
||||
const marketColumns = getColumns("persona_market");
|
||||
ensureColumn("persona_market", marketColumns, "persona_id", "ALTER TABLE persona_market ADD COLUMN persona_id INTEGER");
|
||||
ensureColumn("persona_market", marketColumns, "usage_count", "ALTER TABLE persona_market ADD COLUMN usage_count INTEGER DEFAULT 0");
|
||||
ensureColumn("persona_market", marketColumns, "example_dialogues", "ALTER TABLE persona_market ADD COLUMN example_dialogues TEXT");
|
||||
@@ -277,14 +385,19 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
||||
ensureColumn("persona_market", marketColumns, "moderation_status", "ALTER TABLE persona_market ADD COLUMN moderation_status TEXT DEFAULT 'approved'");
|
||||
ensureColumn("persona_market", marketColumns, "moderation_notes", "ALTER TABLE persona_market ADD COLUMN moderation_notes TEXT");
|
||||
ensureColumn("persona_market", marketColumns, "soft_deleted_at", "ALTER TABLE persona_market ADD COLUMN soft_deleted_at DATETIME");
|
||||
// NULL-Werte auf den Default setzen (z. B. aus Versionen, in denen explizit NULL gespeichert wurde)
|
||||
db.prepare("UPDATE persona_market SET usage_count = 0 WHERE usage_count IS NULL").run();
|
||||
db.prepare("UPDATE persona_market SET favorite_count = 0 WHERE favorite_count IS NULL").run();
|
||||
db.prepare("UPDATE persona_market SET rating_total = 0 WHERE rating_total IS NULL").run();
|
||||
db.prepare("UPDATE persona_market SET rating_count = 0 WHERE rating_count IS NULL").run();
|
||||
|
||||
const chatMessageColumns = db.prepare("PRAGMA table_info(chat_messages)").all();
|
||||
// --- chat_messages ---
|
||||
const chatMessageColumns = getColumns("chat_messages");
|
||||
// Nur hier definiert, nicht in initializeSchema: wird also auch bei neuen DBs gebraucht
|
||||
ensureColumn("chat_messages", chatMessageColumns, "sources", "ALTER TABLE chat_messages ADD COLUMN sources TEXT");
|
||||
ensureColumn("chat_messages", chatMessageColumns, "thinking", "ALTER TABLE chat_messages ADD COLUMN thinking TEXT");
|
||||
// Muss VOR dem ensureColumn für parent_message_id geprüft werden:
|
||||
// Nur wenn die Spalte neu ist, wird unten der Nachrichtenbaum aufgebaut.
|
||||
const hadParentMessageId = chatMessageColumns.some((column) => column.name === "parent_message_id");
|
||||
ensureColumn("chat_messages", chatMessageColumns, "model_name", "ALTER TABLE chat_messages ADD COLUMN model_name TEXT");
|
||||
ensureColumn("chat_messages", chatMessageColumns, "retry_variants", "ALTER TABLE chat_messages ADD COLUMN retry_variants TEXT");
|
||||
@@ -298,7 +411,8 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
||||
db.prepare("UPDATE chat_messages SET retry_retries_used = 0 WHERE retry_retries_used IS NULL").run();
|
||||
db.prepare("UPDATE chat_messages SET delivery_status = 'complete' WHERE delivery_status IS NULL").run();
|
||||
|
||||
const chatSessionColumns = db.prepare("PRAGMA table_info(chat_sessions)").all();
|
||||
// --- chat_sessions ---
|
||||
const chatSessionColumns = getColumns("chat_sessions");
|
||||
ensureColumn("chat_sessions", chatSessionColumns, "assistant_persona_id", "ALTER TABLE chat_sessions ADD COLUMN assistant_persona_id INTEGER");
|
||||
ensureColumn("chat_sessions", chatSessionColumns, "user_persona_id", "ALTER TABLE chat_sessions ADD COLUMN user_persona_id INTEGER");
|
||||
ensureColumn("chat_sessions", chatSessionColumns, "folder_name", "ALTER TABLE chat_sessions ADD COLUMN folder_name TEXT");
|
||||
@@ -318,6 +432,11 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
||||
ensureColumn("chat_sessions", chatSessionColumns, "system_instruction", "ALTER TABLE chat_sessions ADD COLUMN system_instruction TEXT");
|
||||
db.prepare("UPDATE chat_sessions SET is_pinned = 0 WHERE is_pinned IS NULL").run();
|
||||
db.prepare("UPDATE chat_sessions SET context_summary_message_id = 0 WHERE context_summary_message_id IS NULL").run();
|
||||
|
||||
// Einmalig: Nachrichtenbaum für alte Datenbanken aufbauen.
|
||||
// Früher waren Nachrichten eine flache Liste. Jede Nachricht bekommt jetzt ihre
|
||||
// Vorgängernachricht im selben Chat als Parent (LAG = "Wert der vorherigen Zeile"),
|
||||
// und jeder Chat zeigt auf seine letzte Nachricht als aktives Blatt.
|
||||
if (!hadParentMessageId) {
|
||||
db.exec(`
|
||||
WITH ordered AS (
|
||||
@@ -335,129 +454,35 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
||||
`);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS chat_memories
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
chat_id INTEGER NOT NULL,
|
||||
assistant_persona_id INTEGER,
|
||||
user_persona_id INTEGER,
|
||||
fact TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (username) REFERENCES users (username) ON DELETE CASCADE,
|
||||
FOREIGN KEY (chat_id) REFERENCES chat_sessions (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assistant_persona_id) REFERENCES personas (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_persona_id) REFERENCES personas (id) ON DELETE SET NULL
|
||||
)
|
||||
`).run();
|
||||
// Tabellen, die in älteren Versionen fehlten.
|
||||
// Identisch zu initializeSchema und dort bereits angelegt, daher eigentlich überflüssig.
|
||||
// [HIER die sechs CREATE TABLE-Blöcke unverändert einfügen:
|
||||
// chat_memories, persona_chat_state, persona_versions,
|
||||
// persona_market_favorites, persona_market_ratings,
|
||||
// persona_market_reports, prompt_templates]
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS persona_chat_state
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
chat_id INTEGER NOT NULL UNIQUE,
|
||||
assistant_persona_id INTEGER,
|
||||
user_persona_id INTEGER,
|
||||
relationship_notes TEXT,
|
||||
tone TEXT,
|
||||
current_location TEXT,
|
||||
goals TEXT,
|
||||
unresolved_threads TEXT,
|
||||
boundaries TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (username) REFERENCES users (username) ON DELETE CASCADE,
|
||||
FOREIGN KEY (chat_id) REFERENCES chat_sessions (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assistant_persona_id) REFERENCES personas (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_persona_id) REFERENCES personas (id) ON DELETE SET NULL
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS persona_versions
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
persona_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS persona_market_favorites
|
||||
(
|
||||
market_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (market_id, username),
|
||||
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS persona_market_ratings
|
||||
(
|
||||
market_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (market_id, username),
|
||||
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS persona_market_reports
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
market_id INTEGER NOT NULL,
|
||||
reporter_username TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
details TEXT,
|
||||
status TEXT DEFAULT 'open',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (market_id) REFERENCES persona_market (id) ON DELETE CASCADE
|
||||
)
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS prompt_templates
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT,
|
||||
persona_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
prompt_text TEXT NOT NULL,
|
||||
starter_text TEXT,
|
||||
is_shared INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (persona_id) REFERENCES personas (id) ON DELETE CASCADE
|
||||
)
|
||||
`).run();
|
||||
|
||||
const legacyChatsExist = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'").get();
|
||||
// --- Einmalig: Übernahme der alten Tabelle "chats" ---
|
||||
// Früher gab es pro Benutzer nur eine flache Nachrichtenliste ohne Chat-Sessions.
|
||||
const legacyChatsExist = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chats'")
|
||||
.get();
|
||||
if (!legacyChatsExist) {
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationNeeded = db.prepare("SELECT COUNT(*) as count FROM chat_messages").get();
|
||||
if (migrationNeeded.count === 0) {
|
||||
// Nur übernehmen, wenn es noch keine neuen Nachrichten gibt,
|
||||
// damit bei einem erneuten Start nichts doppelt importiert wird.
|
||||
const migrationNeeded = db
|
||||
.prepare<[], { count: number }>("SELECT COUNT(*) AS count FROM chat_messages")
|
||||
.get();
|
||||
if (migrationNeeded?.count === 0) {
|
||||
const insertChatSessionStmt = db.prepare(
|
||||
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const getLegacyUsersStmt = db.prepare("SELECT DISTINCT username FROM chats");
|
||||
const getLegacyChatsStmt = db.prepare(`
|
||||
const getLegacyUsersStmt = db.prepare<[], { username: string }>(
|
||||
"SELECT DISTINCT username FROM chats"
|
||||
);
|
||||
const getLegacyChatsStmt = db.prepare<[string], { role: string; content: string }>(`
|
||||
SELECT role, content
|
||||
FROM chats
|
||||
WHERE username = ?
|
||||
@@ -466,8 +491,11 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
||||
const insertChatMessageStmt = db.prepare(
|
||||
"INSERT INTO chat_messages (chat_id, role, content, retry_variants, retry_active_index, retry_retries_used, retry_prompt_message_id) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
// In einer Transaktion: Entweder wird alles übernommen oder gar nichts.
|
||||
// Pro Benutzer entsteht ein Chat "Imported chat" mit allen alten Nachrichten.
|
||||
const migrateLegacyChats = db.transaction(() => {
|
||||
for (const {username} of getLegacyUsersStmt.all()) {
|
||||
for (const { username } of getLegacyUsersStmt.all()) {
|
||||
const chatId = insertChatSessionStmt.run(username, "Imported chat", null, null, null, null).lastInsertRowid;
|
||||
for (const row of getLegacyChatsStmt.all(username)) {
|
||||
insertChatMessageStmt.run(chatId, row.role, row.content, null, 0, 0, null);
|
||||
@@ -480,4 +508,4 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
||||
if (dropLegacyChats) {
|
||||
db.prepare("DROP TABLE chats").run();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user