Add database schema, encryption utilities, and local database service

- 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.
This commit is contained in:
natreex
2025-11-17 09:34:54 -05:00
parent 09768aafcf
commit d5eb1691d9
12 changed files with 2763 additions and 197 deletions

View File

@@ -0,0 +1,457 @@
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;
}