- Implement `schema.ts` for SQLite schema creation, indexing, and sync metadata initialization. - Develop `encryption.ts` with AES-256-GCM encryption utilities for securing database data. - Add `database.service.ts` to manage CRUD operations with encryption support, user-specific databases, and schema initialization. - Integrate book, chapter, and character operations with encrypted content handling and sync preparation.
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
import sqlite3 from 'node-sqlite3-wasm';
|
|
import path from 'path';
|
|
import { app } from 'electron';
|
|
import { initializeSchema } from './schema.js';
|
|
import { encrypt, decrypt, encryptObject, decryptObject, hash } from './encryption.js';
|
|
|
|
// Type alias for compatibility
|
|
type Database = sqlite3.Database;
|
|
|
|
// Mappers
|
|
import * as BookMapper from './mappers/book.mapper.js';
|
|
import * as ChapterMapper from './mappers/chapter.mapper.js';
|
|
import * as CharacterMapper from './mappers/character.mapper.js';
|
|
import * as AIMapper from './mappers/ai.mapper.js';
|
|
import * as UserMapper from './mappers/user.mapper.js';
|
|
import * as WorldMapper from './mappers/world.mapper.js';
|
|
|
|
// Types from mappers (which contain all necessary interfaces)
|
|
import type { BookProps, BookListProps } from './mappers/book.mapper.js';
|
|
import type { ChapterProps } from './mappers/chapter.mapper.js';
|
|
import type { CharacterProps } from './mappers/character.mapper.js';
|
|
import type { Conversation, Message } from './mappers/ai.mapper.js';
|
|
import type { UserProps } from './mappers/user.mapper.js';
|
|
import type { WorldProps } from './mappers/world.mapper.js';
|
|
|
|
/**
|
|
* DatabaseService - Handles all local database operations
|
|
* Provides CRUD operations with automatic encryption/decryption
|
|
* Maps between DB snake_case and TypeScript camelCase interfaces
|
|
*/
|
|
export class DatabaseService {
|
|
private db: Database | null = null;
|
|
private userEncryptionKey: string | null = null;
|
|
private userId: string | null = null;
|
|
|
|
constructor() {}
|
|
|
|
/**
|
|
* Initialize the database for a specific user
|
|
* @param userId - User ID for encryption key
|
|
* @param encryptionKey - User's encryption key (generated at first login)
|
|
*/
|
|
initialize(userId: string, encryptionKey: string): void {
|
|
if (this.db) {
|
|
this.close();
|
|
}
|
|
|
|
// Get user data directory
|
|
const userDataPath = app.getPath('userData');
|
|
const dbPath = path.join(userDataPath, `eritors-local-${userId}.db`);
|
|
|
|
this.db = new sqlite3.Database(dbPath);
|
|
this.userEncryptionKey = encryptionKey;
|
|
this.userId = userId;
|
|
|
|
// Initialize schema
|
|
initializeSchema(this.db);
|
|
|
|
console.log(`Database initialized for user ${userId} at ${dbPath}`);
|
|
}
|
|
|
|
/**
|
|
* Close the database connection
|
|
*/
|
|
close(): void {
|
|
if (this.db) {
|
|
this.db.close();
|
|
this.db = null;
|
|
this.userEncryptionKey = null;
|
|
this.userId = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if database is initialized
|
|
*/
|
|
isInitialized(): boolean {
|
|
return this.db !== null && this.userEncryptionKey !== null;
|
|
}
|
|
|
|
/**
|
|
* Encrypt sensitive field
|
|
*/
|
|
private encryptField(data: string): string {
|
|
if (!this.userEncryptionKey) throw new Error('Encryption key not set');
|
|
const encrypted = encrypt(data, this.userEncryptionKey);
|
|
return JSON.stringify(encrypted);
|
|
}
|
|
|
|
/**
|
|
* Decrypt sensitive field
|
|
*/
|
|
private decryptField(encryptedData: string): string {
|
|
if (!this.userEncryptionKey) throw new Error('Encryption key not set');
|
|
try {
|
|
const parsed = JSON.parse(encryptedData);
|
|
return decrypt(parsed, this.userEncryptionKey);
|
|
} catch {
|
|
// If not encrypted (for migration), return as-is
|
|
return encryptedData;
|
|
}
|
|
}
|
|
|
|
// ========== BOOK OPERATIONS ==========
|
|
|
|
/**
|
|
* Get all books for the current user
|
|
*/
|
|
getBooks(): BookListProps[] {
|
|
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
|
|
|
const rows = this.db.all(`
|
|
SELECT * FROM erit_books
|
|
WHERE author_id = ?
|
|
ORDER BY book_id DESC
|
|
`, [this.userId]) as unknown as BookMapper.DBBook[];
|
|
|
|
return rows.map(row => BookMapper.dbToBookList(row));
|
|
}
|
|
|
|
/**
|
|
* Get a single book by ID with all related data
|
|
*/
|
|
getBook(bookId: string): BookProps | null {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
const rows = this.db.all('SELECT * FROM erit_books WHERE book_id = ?', [bookId]) as unknown as BookMapper.DBBook[];
|
|
const row = rows[0];
|
|
|
|
if (!row) return null;
|
|
|
|
const book = BookMapper.dbToBook(row);
|
|
|
|
// Load chapters
|
|
const chapterRows = this.db.all(`
|
|
SELECT * FROM book_chapters
|
|
WHERE book_id = ?
|
|
ORDER BY chapter_order ASC
|
|
`, [bookId]) as unknown as ChapterMapper.DBChapter[];
|
|
|
|
book.chapters = chapterRows.map(chapterRow => {
|
|
// Load chapter content
|
|
const contentRows = this.db!.all(`
|
|
SELECT * FROM book_chapter_content
|
|
WHERE chapter_id = ?
|
|
ORDER BY version DESC
|
|
LIMIT 1
|
|
`, [chapterRow.chapter_id]) as unknown as ChapterMapper.DBChapterContent[];
|
|
const contentRow = contentRows[0];
|
|
|
|
// Decrypt content if encrypted
|
|
if (contentRow && contentRow.content) {
|
|
try {
|
|
contentRow.content = this.decryptField(contentRow.content);
|
|
} catch (error) {
|
|
console.warn('Failed to decrypt chapter content:', error);
|
|
}
|
|
}
|
|
|
|
return ChapterMapper.dbToChapter(chapterRow, contentRow);
|
|
});
|
|
|
|
return book;
|
|
}
|
|
|
|
/**
|
|
* Save or update a book
|
|
*/
|
|
saveBook(book: BookProps | BookListProps, authorId?: string): void {
|
|
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
|
|
|
const dbBook = 'bookId' in book
|
|
? BookMapper.bookToDb(book, authorId || this.userId, 0)
|
|
: BookMapper.bookListToDb(book, 0);
|
|
|
|
// Hash the title
|
|
dbBook.hashed_title = hash(dbBook.title);
|
|
if (dbBook.sub_title) {
|
|
dbBook.hashed_sub_title = hash(dbBook.sub_title);
|
|
}
|
|
|
|
this.db.run(`
|
|
INSERT OR REPLACE INTO erit_books (
|
|
book_id, type, author_id, title, hashed_title, sub_title, hashed_sub_title,
|
|
summary, serie_id, desired_release_date, desired_word_count, words_count,
|
|
cover_image, book_meta, synced
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
dbBook.book_id, dbBook.type, dbBook.author_id, dbBook.title, dbBook.hashed_title,
|
|
dbBook.sub_title ?? null, dbBook.hashed_sub_title, dbBook.summary, dbBook.serie_id ?? null,
|
|
dbBook.desired_release_date ?? null, dbBook.desired_word_count ?? null, dbBook.words_count ?? null,
|
|
dbBook.cover_image ?? null, dbBook.book_meta ?? null, 0
|
|
]);
|
|
|
|
// Add to pending changes for sync
|
|
this.addPendingChange('erit_books', 'INSERT', dbBook.book_id, dbBook);
|
|
}
|
|
|
|
/**
|
|
* Delete a book
|
|
*/
|
|
deleteBook(bookId: string): void {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
this.db.run('DELETE FROM erit_books WHERE book_id = ?', [bookId]);
|
|
this.addPendingChange('erit_books', 'DELETE', bookId);
|
|
}
|
|
|
|
// ========== CHAPTER OPERATIONS ==========
|
|
|
|
/**
|
|
* Save or update a chapter
|
|
*/
|
|
saveChapter(chapter: ChapterProps, bookId: string, contentId?: string): void {
|
|
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
|
|
|
const dbChapter = ChapterMapper.chapterToDb(chapter, bookId, this.userId, 0);
|
|
dbChapter.hashed_title = hash(dbChapter.title);
|
|
|
|
this.db.run(`
|
|
INSERT OR REPLACE INTO book_chapters (
|
|
chapter_id, book_id, author_id, title, hashed_title, words_count,
|
|
chapter_order, meta_chapter, synced
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
dbChapter.chapter_id, dbChapter.book_id, dbChapter.author_id, dbChapter.title,
|
|
dbChapter.hashed_title, dbChapter.words_count ?? null, dbChapter.chapter_order ?? null,
|
|
dbChapter.meta_chapter, 0
|
|
]);
|
|
|
|
// Save encrypted content
|
|
const dbContent = ChapterMapper.chapterContentToDb(
|
|
chapter.chapterContent,
|
|
contentId || crypto.randomUUID(),
|
|
chapter.chapterId,
|
|
this.userId,
|
|
0,
|
|
0
|
|
);
|
|
|
|
// Encrypt the content
|
|
dbContent.content = this.encryptField(dbContent.content);
|
|
|
|
this.db.run(`
|
|
INSERT OR REPLACE INTO book_chapter_content (
|
|
content_id, chapter_id, author_id, version, content, words_count,
|
|
meta_chapter_content, time_on_it, synced
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
dbContent.content_id, dbContent.chapter_id, dbContent.author_id, dbContent.version,
|
|
dbContent.content, dbContent.words_count, dbContent.meta_chapter_content,
|
|
dbContent.time_on_it, 0
|
|
]);
|
|
|
|
this.addPendingChange('book_chapters', 'INSERT', chapter.chapterId, dbChapter);
|
|
}
|
|
|
|
// ========== CHARACTER OPERATIONS ==========
|
|
|
|
/**
|
|
* Get all characters for a book
|
|
*/
|
|
getCharacters(bookId: string): CharacterProps[] {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
const characterRows = this.db.all('SELECT * FROM book_characters WHERE book_id = ?', [bookId]) as unknown as CharacterMapper.DBCharacter[];
|
|
|
|
return characterRows.map(charRow => {
|
|
const attrRows = this.db!.all('SELECT * FROM book_characters_attributes WHERE character_id = ?', [charRow.character_id]) as unknown as CharacterMapper.DBCharacterAttribute[];
|
|
return CharacterMapper.dbToCharacter(charRow, attrRows);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save or update a character
|
|
*/
|
|
saveCharacter(character: CharacterProps, bookId: string): void {
|
|
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
|
|
|
const dbCharacter = CharacterMapper.characterToDb(character, bookId, this.userId, 0);
|
|
|
|
this.db.run(`
|
|
INSERT OR REPLACE INTO book_characters (
|
|
character_id, book_id, user_id, first_name, last_name, category, title,
|
|
image, role, biography, history, char_meta, synced
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
dbCharacter.character_id, dbCharacter.book_id, dbCharacter.user_id,
|
|
dbCharacter.first_name, dbCharacter.last_name ?? null, dbCharacter.category,
|
|
dbCharacter.title ?? null, dbCharacter.image ?? null, dbCharacter.role ?? null, dbCharacter.biography ?? null,
|
|
dbCharacter.history ?? null, dbCharacter.char_meta, 0
|
|
]);
|
|
|
|
// Delete old attributes and insert new ones
|
|
this.db.run('DELETE FROM book_characters_attributes WHERE character_id = ?', [dbCharacter.character_id]);
|
|
|
|
const attributes = CharacterMapper.characterAttributesToDb(character, this.userId, 0);
|
|
|
|
for (const attr of attributes) {
|
|
this.db.run(`
|
|
INSERT INTO book_characters_attributes (
|
|
attr_id, character_id, user_id, attribute_name, attribute_value, attr_meta, synced
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
attr.attr_id, attr.character_id, attr.user_id, attr.attribute_name,
|
|
attr.attribute_value, attr.attr_meta, 0
|
|
]);
|
|
}
|
|
|
|
this.addPendingChange('book_characters', 'INSERT', dbCharacter.character_id, dbCharacter);
|
|
}
|
|
|
|
// ========== AI CONVERSATION OPERATIONS ==========
|
|
|
|
/**
|
|
* Get all conversations for a book
|
|
*/
|
|
getConversations(bookId: string): Conversation[] {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
const convoRows = this.db.all('SELECT * FROM ai_conversations WHERE book_id = ? ORDER BY start_date DESC', [bookId]) as unknown as AIMapper.DBConversation[];
|
|
|
|
return convoRows.map(convoRow => {
|
|
const messageRows = this.db!.all('SELECT * FROM ai_messages_history WHERE conversation_id = ? ORDER BY message_date ASC', [convoRow.conversation_id]) as unknown as AIMapper.DBMessage[];
|
|
|
|
// Decrypt messages
|
|
messageRows.forEach(msg => {
|
|
try {
|
|
msg.message = this.decryptField(msg.message);
|
|
} catch (error) {
|
|
console.warn('Failed to decrypt AI message:', error);
|
|
}
|
|
});
|
|
|
|
return AIMapper.dbToConversation(convoRow, messageRows);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save a conversation with messages
|
|
*/
|
|
saveConversation(conversation: Conversation, bookId: string): void {
|
|
if (!this.db || !this.userId) throw new Error('Database not initialized');
|
|
|
|
const dbConvo = AIMapper.conversationToDb(conversation, bookId, this.userId, 0);
|
|
|
|
this.db.run(`
|
|
INSERT OR REPLACE INTO ai_conversations (
|
|
conversation_id, book_id, mode, title, start_date, status, user_id, summary, convo_meta, synced
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
dbConvo.conversation_id, dbConvo.book_id, dbConvo.mode, dbConvo.title,
|
|
dbConvo.start_date, dbConvo.status, dbConvo.user_id, dbConvo.summary ?? null,
|
|
dbConvo.convo_meta, 0
|
|
]);
|
|
|
|
// Save encrypted messages
|
|
for (const message of conversation.messages) {
|
|
const dbMessage = AIMapper.messageToDb(message, conversation.id, 0);
|
|
// Encrypt the message content
|
|
dbMessage.message = this.encryptField(dbMessage.message);
|
|
|
|
this.db.run(`
|
|
INSERT OR REPLACE INTO ai_messages_history (
|
|
message_id, conversation_id, role, message, message_date, meta_message, synced
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
dbMessage.message_id, dbMessage.conversation_id, dbMessage.role,
|
|
dbMessage.message, dbMessage.message_date, dbMessage.meta_message, 0
|
|
]);
|
|
}
|
|
|
|
this.addPendingChange('ai_conversations', 'INSERT', dbConvo.conversation_id, dbConvo);
|
|
}
|
|
|
|
// ========== SYNC OPERATIONS ==========
|
|
|
|
/**
|
|
* Add a pending change for sync
|
|
*/
|
|
private addPendingChange(tableName: string, operation: string, recordId: string, data?: any): void {
|
|
if (!this.db) return;
|
|
|
|
this.db.run(`
|
|
INSERT INTO _pending_changes (table_name, operation, record_id, data, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, [tableName, operation, recordId, data ? JSON.stringify(data) : null, Date.now()]);
|
|
|
|
// Update sync metadata
|
|
this.db.run(`
|
|
UPDATE _sync_metadata
|
|
SET pending_changes = pending_changes + 1
|
|
WHERE table_name = ?
|
|
`, [tableName]);
|
|
}
|
|
|
|
/**
|
|
* Get pending changes for sync
|
|
*/
|
|
getPendingChanges(limit: number = 100): any[] {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
return this.db.all(`
|
|
SELECT * FROM _pending_changes
|
|
ORDER BY created_at ASC
|
|
LIMIT ?
|
|
`, [limit]) as any[];
|
|
}
|
|
|
|
/**
|
|
* Mark changes as synced
|
|
*/
|
|
markChangesSynced(changeIds: number[]): void {
|
|
if (!this.db || changeIds.length === 0) return;
|
|
|
|
const placeholders = changeIds.map(() => '?').join(',');
|
|
this.db.run(`DELETE FROM _pending_changes WHERE id IN (${placeholders})`, changeIds);
|
|
}
|
|
|
|
/**
|
|
* Update last sync time for a table
|
|
*/
|
|
updateLastSync(tableName: string): void {
|
|
if (!this.db) return;
|
|
|
|
this.db.run(`
|
|
UPDATE _sync_metadata
|
|
SET last_sync_at = ?, pending_changes = 0
|
|
WHERE table_name = ?
|
|
`, [Date.now(), tableName]);
|
|
}
|
|
|
|
/**
|
|
* Get sync status
|
|
*/
|
|
getSyncStatus(): { table: string; lastSync: number; pending: number }[] {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
const rows = this.db.all('SELECT * FROM _sync_metadata', []) as unknown as any[];
|
|
|
|
return rows.map(row => ({
|
|
table: row.table_name as string,
|
|
lastSync: row.last_sync_at as number,
|
|
pending: row.pending_changes as number
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let dbServiceInstance: DatabaseService | null = null;
|
|
|
|
export function getDatabaseService(): DatabaseService {
|
|
if (!dbServiceInstance) {
|
|
dbServiceInstance = new DatabaseService();
|
|
}
|
|
return dbServiceInstance;
|
|
}
|