index.ts•7.92 kB
import Database from 'better-sqlite3';
import path from 'path';
import os from 'os';
import { DbFeed, DbFeedItem } from 'shared-types';
// Database configuration
const DEFAULT_DB_DIR = path.join(os.homedir(), '.mcp-rss-crawler');
const DEFAULT_DB_FILE = path.join(DEFAULT_DB_DIR, 'feeds.db');
// Database connection options
export interface DbOptions {
  dbPath?: string;
  readonly?: boolean;
}
// Create singleton database connections
let readOnlyDb: Database.Database | null = null;
let writeDb: Database.Database | null = null;
/**
 * Get a read-only database connection
 */
export function getDb(options: DbOptions = {}): Database.Database {
  const dbPath = options.dbPath || process.env.DB_FILE || DEFAULT_DB_FILE;
  
  if (!readOnlyDb) {
    try {
      readOnlyDb = new Database(dbPath, { readonly: true });
      console.log(`Connected to database at ${dbPath}`);
    } catch (error) {
      console.error('Error connecting to database:', error);
      throw new Error('Failed to connect to the database');
    }
  }
  return readOnlyDb;
}
/**
 * Get a writable database connection
 */
export function getWriteDb(options: DbOptions = {}): Database.Database {
  const dbPath = options.dbPath || process.env.DB_FILE || DEFAULT_DB_FILE;
  
  if (!writeDb) {
    try {
      writeDb = new Database(dbPath);
      console.log(`Connected to database for writing at ${dbPath}`);
    } catch (error) {
      console.error('Error connecting to database for writing:', error);
      throw new Error('Failed to connect to the database for writing');
    }
  }
  return writeDb;
}
/**
 * Close all database connections
 */
export function closeDb(): void {
  if (readOnlyDb) {
    readOnlyDb.close();
    readOnlyDb = null;
  }
  if (writeDb) {
    writeDb.close();
    writeDb = null;
  }
}
// Feed operations
export const feedOperations = {
  /**
   * Get all feeds
   */
  getAllFeeds(options: DbOptions = {}): DbFeed[] {
    const db = getDb(options);
    return db.prepare('SELECT * FROM feeds ORDER BY category, name').all() as DbFeed[];
  },
  /**
   * Get feeds by category
   */
  getFeedsByCategory(category: string, options: DbOptions = {}): DbFeed[] {
    const db = getDb(options);
    return db.prepare('SELECT * FROM feeds WHERE category = ? ORDER BY name').all(category) as DbFeed[];
  },
  /**
   * Get a feed by ID
   */
  getFeedById(id: string, options: DbOptions = {}): DbFeed | null {
    const db = getDb(options);
    return db.prepare('SELECT * FROM feeds WHERE id = ?').get(id) as DbFeed | null;
  },
  /**
   * Add a new feed
   */
  addFeed(feed: Omit<DbFeed, 'id'>, options: DbOptions = {}): string {
    const db = getWriteDb(options);
    const id = crypto.randomUUID();
    
    db.prepare(`
      INSERT INTO feeds (id, url, name, category, last_updated)
      VALUES (?, ?, ?, ?, ?)
    `).run(id, feed.url, feed.name, feed.category || null, feed.last_updated || Date.now());
    
    return id;
  },
  /**
   * Update a feed
   */
  updateFeed(feed: DbFeed, options: DbOptions = {}): void {
    const db = getWriteDb(options);
    
    db.prepare(`
      UPDATE feeds
      SET url = ?, name = ?, category = ?, last_updated = ?
      WHERE id = ?
    `).run(feed.url, feed.name, feed.category || null, feed.last_updated, feed.id);
  },
  /**
   * Delete a feed
   */
  deleteFeed(id: string, options: DbOptions = {}): void {
    const db = getWriteDb(options);
    
    // Start a transaction to delete feed and its items
    const transaction = db.transaction(() => {
      // Delete items first (foreign key constraint)
      db.prepare('DELETE FROM items WHERE feed_id = ?').run(id);
      // Delete the feed
      db.prepare('DELETE FROM feeds WHERE id = ?').run(id);
    });
    
    transaction();
  }
};
// Category operations
export const categoryOperations = {
  /**
   * Get all categories
   */
  getAllCategories(options: DbOptions = {}): string[] {
    const db = getDb(options);
    const categories = db.prepare('SELECT DISTINCT category FROM feeds WHERE category IS NOT NULL').all() as { category: string }[];
    return categories.map(c => c.category);
  }
};
// Item operations
export const itemOperations = {
  /**
   * Get items from a specific feed
   */
  getItemsByFeed(feedId: string, limit: number = 50, options: DbOptions = {}): DbFeedItem[] {
    const db = getDb(options);
    return db.prepare(`
      SELECT i.*, f.name as feed_title, f.url as feed_url
      FROM items i
      JOIN feeds f ON i.feed_id = f.id
      WHERE i.feed_id = ?
      ORDER BY i.published DESC
      LIMIT ?
    `).all(feedId, limit) as DbFeedItem[];
  },
  /**
   * Get recent items
   */
  getRecentItems(limit: number = 50, options: DbOptions = {}): DbFeedItem[] {
    const db = getDb(options);
    return db.prepare(`
      SELECT i.*, f.name as feed_title, f.url as feed_url
      FROM items i
      JOIN feeds f ON i.feed_id = f.id
      ORDER BY i.published DESC
      LIMIT ?
    `).all(limit) as DbFeedItem[];
  },
  /**
   * Get items by category
   */
  getItemsByCategory(category: string, limit: number = 50, options: DbOptions = {}): DbFeedItem[] {
    const db = getDb(options);
    return db.prepare(`
      SELECT i.*, f.name as feed_title, f.url as feed_url
      FROM items i
      JOIN feeds f ON i.feed_id = f.id
      WHERE f.category = ?
      ORDER BY i.published DESC
      LIMIT ?
    `).all(category, limit) as DbFeedItem[];
  },
  /**
   * Search items
   */
  searchItems(query: string, limit: number = 50, options: DbOptions = {}): DbFeedItem[] {
    const db = getDb(options);
    const searchPattern = `%${query}%`;
    return db.prepare(`
      SELECT i.*, f.name as feed_title, f.url as feed_url
      FROM items i
      JOIN feeds f ON i.feed_id = f.id
      WHERE i.title LIKE ? OR i.summary LIKE ? OR i.content LIKE ?
      ORDER BY i.published DESC
      LIMIT ?
    `).all(searchPattern, searchPattern, searchPattern, limit) as DbFeedItem[];
  },
  /**
   * Get categories for an item
   */
  getItemCategories(itemId: string, options: DbOptions = {}): string[] {
    const db = getDb(options);
    const rows = db.prepare('SELECT category FROM categories WHERE item_id = ?').all(itemId) as { category: string }[];
    return rows.map(row => row.category);
  },
  /**
   * Get a single item by ID
   */
  getItemById(itemId: string, options: DbOptions = {}): DbFeedItem | null {
    const db = getDb(options);
    const item = db.prepare(`
      SELECT i.*, f.name as feed_title, f.url as feed_url
      FROM items i
      JOIN feeds f ON i.feed_id = f.id
      WHERE i.id = ?
    `).get(itemId) as DbFeedItem | null;
    
    if (item) {
      item.categories = this.getItemCategories(itemId, options);
    }
    
    return item;
  },
  /**
   * Add a new item
   */
  addItem(item: Omit<DbFeedItem, 'id'>, options: DbOptions = {}): string {
    const db = getWriteDb(options);
    const id = item.id || crypto.randomUUID();
    
    // Start a transaction
    const transaction = db.transaction(() => {
      // Insert the item
      db.prepare(`
        INSERT INTO items (id, feed_id, title, link, summary, content, published, author)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
      `).run(
        id,
        item.feed_id,
        item.title,
        item.link || '',
        item.summary || null,
        item.content || null,
        item.published,
        item.author || null
      );
      
      // Insert categories if any
      if (item.categories && item.categories.length > 0) {
        const insertCategory = db.prepare('INSERT INTO categories (item_id, category) VALUES (?, ?)');
        for (const category of item.categories) {
          insertCategory.run(id, category);
        }
      }
    });
    
    transaction();
    return id;
  }
};
// Export all operations
export default {
  getDb,
  getWriteDb,
  closeDb,
  feeds: feedOperations,
  categories: categoryOperations,
  items: itemOperations
};