diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fc4f632 --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# Pulse RSS Feed Aggregator - Environment Configuration + +# ============================================================================= +# DATABASE CONFIGURATION +# ============================================================================= + +# Required: Database type - 'sqlite' or 'postgres' +# Default: sqlite +PULSE_DATABASE_TYPE=sqlite + +# ----------------------------------------------------------------------------- +# SQLite Configuration (used when PULSE_DATABASE_TYPE=sqlite) +# ----------------------------------------------------------------------------- + +# Path to SQLite database file +# Default: ./data/pulse.db +PULSE_SQLITE_PATH=./data/pulse.db + +# ----------------------------------------------------------------------------- +# PostgreSQL Configuration (used when PULSE_DATABASE_TYPE=postgres) +# ----------------------------------------------------------------------------- + +# Option 1: Connection string (simplest) +# Format: postgresql://user:password@host:port/database +PULSE_DATABASE_URL=postgresql://pulse:password@localhost:5432/pulse + +# Option 2: Individual connection parameters (alternative to connection string) +# PULSE_POSTGRES_HOST=localhost +# PULSE_POSTGRES_PORT=5432 +# PULSE_POSTGRES_DATABASE=pulse +# PULSE_POSTGRES_USER=pulse +# PULSE_POSTGRES_PASSWORD=secret + +# ============================================================================= +# EXAMPLES +# ============================================================================= + +# Local development with SQLite (default): +# PULSE_DATABASE_TYPE=sqlite +# PULSE_SQLITE_PATH=./data/pulse.db + +# Production with PostgreSQL using connection string: +# PULSE_DATABASE_TYPE=postgres +# PULSE_DATABASE_URL=postgresql://pulse:secret@db.example.com:5432/pulse + +# Production with PostgreSQL using individual params: +# PULSE_DATABASE_TYPE=postgres +# PULSE_POSTGRES_HOST=db.example.com +# PULSE_POSTGRES_PORT=5432 +# PULSE_POSTGRES_DATABASE=pulse +# PULSE_POSTGRES_USER=pulse +# PULSE_POSTGRES_PASSWORD=secret diff --git a/infrastructure/db/config.ts b/infrastructure/db/config.ts new file mode 100644 index 0000000..88926d8 --- /dev/null +++ b/infrastructure/db/config.ts @@ -0,0 +1,91 @@ +/** + * Database configuration types and loader. + * Supports SQLite (local) and PostgreSQL (production) via environment variables. + */ + +export interface SqliteConfig { + path: string; +} + +export interface PostgresConfig { + host: string; + port: number; + database: string; + user: string; + password: string; +} + +export interface PostgresUrlConfig { + connectionString: string; +} + +export type DatabaseType = 'sqlite' | 'postgres'; + +export interface DatabaseConfig { + type: DatabaseType; + sqlite?: SqliteConfig; + postgres?: PostgresConfig | PostgresUrlConfig; +} + +function getEnvVar(name: string, defaultValue?: string): string { + const value = process.env[name]; + if (value === undefined) { + if (defaultValue !== undefined) { + return defaultValue; + } + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export function loadConfig(): DatabaseConfig { + const type = getEnvVar('PULSE_DATABASE_TYPE', 'sqlite') as DatabaseType; + + if (type === 'sqlite') { + return { + type: 'sqlite', + sqlite: { + path: getEnvVar('PULSE_SQLITE_PATH', './data/pulse.db'), + }, + }; + } + + if (type === 'postgres') { + const connectionString = process.env.PULSE_DATABASE_URL; + + if (connectionString) { + return { + type: 'postgres', + postgres: { connectionString }, + }; + } + + return { + type: 'postgres', + postgres: { + host: getEnvVar('PULSE_POSTGRES_HOST'), + port: parseInt(getEnvVar('PULSE_POSTGRES_PORT', '5432'), 10), + database: getEnvVar('PULSE_POSTGRES_DATABASE'), + user: getEnvVar('PULSE_POSTGRES_USER'), + password: getEnvVar('PULSE_POSTGRES_PASSWORD'), + }, + }; + } + + throw new Error(`Unsupported database type: ${type}. Use 'sqlite' or 'postgres'.`); +} + +export function validateConfig(config: DatabaseConfig): void { + if (config.type === 'sqlite') { + if (!config.sqlite) { + throw new Error('SQLite configuration missing'); + } + if (!config.sqlite.path) { + throw new Error('SQLite path is required'); + } + } else if (config.type === 'postgres') { + if (!config.postgres) { + throw new Error('PostgreSQL configuration missing'); + } + } +} diff --git a/infrastructure/db/connection.ts b/infrastructure/db/connection.ts new file mode 100644 index 0000000..4682b75 --- /dev/null +++ b/infrastructure/db/connection.ts @@ -0,0 +1,58 @@ +/** + * Database connection factory. + * Creates Kysely instances for SQLite or PostgreSQL based on configuration. + */ + +import { Kysely, SqliteDialect, PostgresDialect } from 'kysely'; +import SqliteDatabase from 'better-sqlite3'; +import { Pool } from 'pg'; +import type { Database } from './database.js'; +import type { DatabaseConfig } from './config.js'; + +export function createDatabase(config: DatabaseConfig): Kysely { + if (config.type === 'sqlite') { + if (!config.sqlite) { + throw new Error('SQLite configuration required'); + } + + const sqliteDb = new SqliteDatabase(config.sqlite.path); + sqliteDb.pragma('journal_mode = WAL'); + + return new Kysely({ + dialect: new SqliteDialect({ + database: sqliteDb, + }), + }); + } + + if (config.type === 'postgres') { + if (!config.postgres) { + throw new Error('PostgreSQL configuration required'); + } + + // Check if using connection string or individual params + if ('connectionString' in config.postgres) { + return new Kysely({ + dialect: new PostgresDialect({ + pool: new Pool({ + connectionString: config.postgres.connectionString, + }), + }), + }); + } else { + return new Kysely({ + dialect: new PostgresDialect({ + pool: new Pool({ + host: config.postgres.host, + port: config.postgres.port, + database: config.postgres.database, + user: config.postgres.user, + password: config.postgres.password, + }), + }), + }); + } + } + + throw new Error(`Unsupported database type: ${config.type}`); +} diff --git a/infrastructure/db/database.ts b/infrastructure/db/database.ts new file mode 100644 index 0000000..221f7c6 --- /dev/null +++ b/infrastructure/db/database.ts @@ -0,0 +1,26 @@ +/** + * Kysely database type definitions. + * Defines the table schemas for type-safe queries. + */ + +export interface FeedItemTable { + id: string; + source: string; + title: string; + url: string; + published_at: string; // ISO 8601 format + content: string | null; + summary: string | null; + created_at: string; // ISO 8601 format +} + +export interface SeenIdTable { + id: string; + seen_at: string; // ISO 8601 format +} + +// Database interface used by Kysely +export interface Database { + feed_items: FeedItemTable; + seen_ids: SeenIdTable; +} diff --git a/infrastructure/db/index.ts b/infrastructure/db/index.ts new file mode 100644 index 0000000..009ac19 --- /dev/null +++ b/infrastructure/db/index.ts @@ -0,0 +1,35 @@ +/** + * Database infrastructure exports. + * Provides configured database instance and schema management. + */ + +import { createDatabase } from './connection.js'; +import { loadConfig, validateConfig, type DatabaseConfig } from './config.js'; +import { migrate, reset } from './schema.js'; +import type { Database } from './database.js'; +import type { Kysely } from 'kysely'; + +// Export types +export type { Database, FeedItemTable, SeenIdTable } from './database.js'; +export type { DatabaseConfig, DatabaseType, SqliteConfig, PostgresConfig, PostgresUrlConfig } from './config.js'; + +// Export functions for creating database instances +export { createDatabase, loadConfig, validateConfig, migrate, reset }; + +/** + * Get a configured database instance. + * Uses environment variables for configuration. + */ +export function getDatabase(): Kysely { + const config = loadConfig(); + validateConfig(config); + return createDatabase(config); +} + +/** + * Get a database instance with explicit config (for testing). + */ +export function getDatabaseWithConfig(config: DatabaseConfig): Kysely { + validateConfig(config); + return createDatabase(config); +} diff --git a/infrastructure/db/schema.ts b/infrastructure/db/schema.ts new file mode 100644 index 0000000..e964d77 --- /dev/null +++ b/infrastructure/db/schema.ts @@ -0,0 +1,54 @@ +/** + * Database schema migrations. + * Creates tables idempotently. + */ + +import type { Kysely } from 'kysely'; +import type { Database } from './database.js'; + +export async function migrate(db: Kysely): Promise { + // Create feed_items table + await db.schema + .createTable('feed_items') + .ifNotExists() + .addColumn('id', 'varchar(64)', (col) => col.primaryKey()) + .addColumn('source', 'varchar(2048)', (col) => col.notNull()) + .addColumn('title', 'varchar(512)', (col) => col.notNull()) + .addColumn('url', 'varchar(2048)', (col) => col.notNull()) + .addColumn('published_at', 'varchar(32)', (col) => col.notNull()) + .addColumn('content', 'text') + .addColumn('summary', 'text') + .addColumn('created_at', 'varchar(32)', (col) => col.notNull().defaultTo('CURRENT_TIMESTAMP')) + .execute(); + + // Create indexes for feed_items + await db.schema + .createIndex('idx_feed_items_source') + .ifNotExists() + .on('feed_items') + .column('source') + .execute(); + + await db.schema + .createIndex('idx_feed_items_published') + .ifNotExists() + .on('feed_items') + .column('published_at') + .execute(); + + // Create seen_ids table + await db.schema + .createTable('seen_ids') + .ifNotExists() + .addColumn('id', 'varchar(64)', (col) => col.primaryKey()) + .addColumn('seen_at', 'varchar(32)', (col) => col.notNull().defaultTo('CURRENT_TIMESTAMP')) + .execute(); +} + +export async function reset(db: Kysely): Promise { + // Drop tables (for testing) + await db.schema.dropTable('seen_ids').ifExists().execute(); + await db.schema.dropIndex('idx_feed_items_published').ifExists().execute(); + await db.schema.dropIndex('idx_feed_items_source').ifExists().execute(); + await db.schema.dropTable('feed_items').ifExists().execute(); +} diff --git a/modules/dedup/dedup.test.ts b/modules/dedup/dedup.test.ts new file mode 100644 index 0000000..a9bca58 --- /dev/null +++ b/modules/dedup/dedup.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import BetterSqlite3 from 'better-sqlite3'; +import { Kysely, SqliteDialect } from 'kysely'; +import { DatabaseDedup } from './dedup.js'; +import { migrate, reset } from '../../infrastructure/db/schema.js'; +import type { Database } from '../../infrastructure/db/database.js'; +import type { FeedItem } from '../../interfaces/feed.types.js'; + +describe('DatabaseDedup', () => { + let sqliteDb: BetterSqlite3.Database; + let db: Kysely; + let dedup: DatabaseDedup; + + beforeEach(async () => { + // Create in-memory database for each test + sqliteDb = new BetterSqlite3(':memory:'); + sqliteDb.pragma('journal_mode = WAL'); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: sqliteDb, + }), + }); + + // Reset and migrate + await reset(db); + await migrate(db); + + dedup = new DatabaseDedup(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + describe('filter', () => { + it('returns all items when nothing is marked seen', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article 1', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Article 2', + url: 'https://example.com/2', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }, + ]; + + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(2); + expect(filtered[0].id).toBe('item1'); + expect(filtered[1].id).toBe('item2'); + }); + + it('excludes items that have been marked seen', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article 1', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Article 2', + url: 'https://example.com/2', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }, + ]; + + // Mark first item as seen + await dedup.markSeen([items[0]]); + + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('item2'); + }); + + it('returns empty array when all items are seen', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article 1', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await dedup.markSeen(items); + + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(0); + }); + + it('returns empty array for empty input', async () => { + const filtered = await dedup.filter([]); + expect(filtered).toHaveLength(0); + }); + + it('handles partial matches correctly', async () => { + const items: FeedItem[] = [ + { + id: 'seen-item', + source: 'https://example.com/feed.xml', + title: 'Seen Article', + url: 'https://example.com/seen', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + { + id: 'new-item', + source: 'https://example.com/feed.xml', + title: 'New Article', + url: 'https://example.com/new', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }, + { + id: 'another-seen', + source: 'https://example.com/feed.xml', + title: 'Another Seen', + url: 'https://example.com/another', + publishedAt: new Date('2024-09-06T11:00:00Z'), + }, + ]; + + await dedup.markSeen([items[0], items[2]]); + + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('new-item'); + }); + }); + + describe('markSeen', () => { + it('marks items as seen', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article 1', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await dedup.markSeen(items); + + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(0); + }); + + it('marks multiple items at once', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article 1', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Article 2', + url: 'https://example.com/2', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }, + ]; + + await dedup.markSeen(items); + + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(0); + }); + + it('handles empty array gracefully', async () => { + await dedup.markSeen([]); + // Should not throw + }); + + it('is idempotent - marking same item twice does not error', async () => { + const item: FeedItem = { + id: 'duplicate-id', + source: 'https://example.com/feed.xml', + title: 'Article', + url: 'https://example.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }; + + await dedup.markSeen([item]); + await dedup.markSeen([item]); // Should not throw + + const filtered = await dedup.filter([item]); + expect(filtered).toHaveLength(0); + }); + + it('marks items incrementally', async () => { + const item1: FeedItem = { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article 1', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }; + + const item2: FeedItem = { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Article 2', + url: 'https://example.com/2', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }; + + // Mark first item + await dedup.markSeen([item1]); + + let filtered = await dedup.filter([item1, item2]); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('item2'); + + // Mark second item + await dedup.markSeen([item2]); + + filtered = await dedup.filter([item1, item2]); + expect(filtered).toHaveLength(0); + }); + }); + + describe('integration scenarios', () => { + it('end-to-end: filter then mark workflow', async () => { + const items: FeedItem[] = [ + { + id: 'new1', + source: 'https://example.com/feed.xml', + title: 'New Article 1', + url: 'https://example.com/new1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + { + id: 'new2', + source: 'https://example.com/feed.xml', + title: 'New Article 2', + url: 'https://example.com/new2', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }, + ]; + + // Simulate feed fetch + const newItems = await dedup.filter(items); + expect(newItems).toHaveLength(2); + + // Mark as seen after display + await dedup.markSeen(newItems); + + // Next fetch should return empty + const nextFetch = await dedup.filter(items); + expect(nextFetch).toHaveLength(0); + }); + + it('handles items with same IDs from different sources', async () => { + // This shouldn't happen with proper ID generation, but test it anyway + const items: FeedItem[] = [ + { + id: 'same-hash-id', + source: 'https://source1.com/feed.xml', + title: 'Article from Source 1', + url: 'https://source1.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + { + id: 'same-hash-id', + source: 'https://source2.com/feed.xml', + title: 'Article from Source 2', + url: 'https://source2.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + // Mark first as seen + await dedup.markSeen([items[0]]); + + // Both should be filtered since they have same ID + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(0); + }); + + it('preserves item data integrity', async () => { + const item: FeedItem = { + id: 'complete-item', + source: 'https://example.com/feed.xml', + title: 'Complete Article', + url: 'https://example.com/complete', + publishedAt: new Date('2024-09-06T09:30:00Z'), + summary: 'A summary', + content: 'Full content', + }; + + // Mark as seen + await dedup.markSeen([item]); + + // Filter should exclude the item + const filtered = await dedup.filter([item]); + expect(filtered).toHaveLength(0); + }); + + it('handles large batches efficiently', async () => { + // Create 100 items + const items: FeedItem[] = Array.from({ length: 100 }, (_, i) => ({ + id: `batch-item-${i}`, + source: 'https://example.com/feed.xml', + title: `Article ${i}`, + url: `https://example.com/${i}`, + publishedAt: new Date(`2024-09-${String(i + 1).padStart(2, '0')}T09:00:00Z`), + })); + + // All should be returned initially + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(100); + + // Mark half as seen + const seenItems = items.slice(0, 50); + await dedup.markSeen(seenItems); + + // Only unseen items should be returned + const filteredAgain = await dedup.filter(items); + expect(filteredAgain).toHaveLength(50); + expect(filteredAgain[0].id).toBe('batch-item-50'); + }); + }); + + describe('edge cases', () => { + it('handles items with special characters in IDs', async () => { + const items: FeedItem[] = [ + { + id: 'item-with-::special::chars', + source: 'https://example.com/feed.xml', + title: 'Special ID Article', + url: 'https://example.com/special', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await dedup.markSeen(items); + + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(0); + }); + + it('handles very long IDs', async () => { + const longId = 'a'.repeat(64); // Max length per schema + const items: FeedItem[] = [ + { + id: longId, + source: 'https://example.com/feed.xml', + title: 'Long ID Article', + url: 'https://example.com/long', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await dedup.markSeen(items); + + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(0); + }); + + it('handles duplicate IDs in single filter call', async () => { + const item: FeedItem = { + id: 'duplicate-in-input', + source: 'https://example.com/feed.xml', + title: 'Article', + url: 'https://example.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }; + + // Same item twice in input + const items = [item, item]; + + // Filter returns both since neither has been marked seen + // (filter only removes items from seen_ids table, not deduplicates input) + const filtered = await dedup.filter(items); + expect(filtered).toHaveLength(2); + + // Mark one as seen + await dedup.markSeen([item]); + + // Now both are filtered since they share the same ID + const filteredAgain = await dedup.filter(items); + expect(filteredAgain).toHaveLength(0); + }); + }); +}); diff --git a/modules/dedup/dedup.ts b/modules/dedup/dedup.ts new file mode 100644 index 0000000..0336a9b --- /dev/null +++ b/modules/dedup/dedup.ts @@ -0,0 +1,55 @@ +/** + * Deduplication module implementation. + * Tracks seen item IDs to filter duplicates from feeds. + */ + +import type { Kysely } from 'kysely'; +import type { FeedItem } from '../../interfaces/feed.types.js'; +import type { IDedup } from '../../interfaces/dedup.interface.js'; +import type { Database, SeenIdTable } from '../../infrastructure/db/database.js'; + +export class DatabaseDedup implements IDedup { + private readonly db: Kysely; + + constructor(db: Kysely) { + this.db = db; + } + + async filter(items: FeedItem[]): Promise { + if (items.length === 0) { + return []; + } + + const ids = items.map((item) => item.id); + + // Query which IDs are already in the seen table + const seenRows = await this.db + .selectFrom('seen_ids') + .select('id') + .where('id', 'in', ids) + .execute(); + + const seenIds = new Set(seenRows.map((row) => row.id)); + + // Return only items NOT in seen table + return items.filter((item) => !seenIds.has(item.id)); + } + + async markSeen(items: FeedItem[]): Promise { + if (items.length === 0) { + return; + } + + const rows: SeenIdTable[] = items.map((item) => ({ + id: item.id, + seen_at: new Date().toISOString(), + })); + + // Insert or ignore (idempotent) + await this.db + .insertInto('seen_ids') + .values(rows) + .onConflict((oc) => oc.column('id').doNothing()) + .execute(); + } +} diff --git a/modules/dedup/index.ts b/modules/dedup/index.ts new file mode 100644 index 0000000..8da62d2 --- /dev/null +++ b/modules/dedup/index.ts @@ -0,0 +1 @@ +export { DatabaseDedup } from './dedup.js'; diff --git a/modules/storage/index.ts b/modules/storage/index.ts new file mode 100644 index 0000000..06408b8 --- /dev/null +++ b/modules/storage/index.ts @@ -0,0 +1 @@ +export { SqlStorage } from './storage.js'; diff --git a/modules/storage/storage.test.ts b/modules/storage/storage.test.ts new file mode 100644 index 0000000..877ba7e --- /dev/null +++ b/modules/storage/storage.test.ts @@ -0,0 +1,430 @@ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import BetterSqlite3 from 'better-sqlite3'; +import { Kysely, SqliteDialect } from 'kysely'; +import { SqlStorage } from './storage.js'; +import { migrate, reset } from '../../infrastructure/db/schema.js'; +import type { Database } from '../../infrastructure/db/database.js'; +import type { FeedItem } from '../../interfaces/feed.types.js'; + +describe('SqlStorage', () => { + let sqliteDb: BetterSqlite3.Database; + let db: Kysely; + let storage: SqlStorage; + + beforeEach(async () => { + // Create in-memory database for each test + sqliteDb = new BetterSqlite3(':memory:'); + sqliteDb.pragma('journal_mode = WAL'); + + db = new Kysely({ + dialect: new SqliteDialect({ + database: sqliteDb, + }), + }); + + // Reset and migrate + await reset(db); + await migrate(db); + + storage = new SqlStorage(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + describe('save', () => { + it('saves a single item', async () => { + const item: FeedItem = { + id: 'abc123', + source: 'https://example.com/feed.xml', + title: 'Test Article', + url: 'https://example.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + summary: 'A summary', + content: 'Full content', + }; + + await storage.save([item]); + + const recent = await storage.getRecent(1); + expect(recent).toHaveLength(1); + expect(recent[0].id).toBe('abc123'); + expect(recent[0].title).toBe('Test Article'); + }); + + it('saves multiple items in batch', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article 1', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }, + { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Article 2', + url: 'https://example.com/2', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await storage.save(items); + + const recent = await storage.getRecent(10); + expect(recent).toHaveLength(2); + }); + + it('handles empty array gracefully', async () => { + await storage.save([]); + const recent = await storage.getRecent(10); + expect(recent).toHaveLength(0); + }); + + it('updates existing item on duplicate id (upsert)', async () => { + const item: FeedItem = { + id: 'same-id', + source: 'https://example.com/feed.xml', + title: 'Original Title', + url: 'https://example.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + summary: 'Original summary', + }; + + await storage.save([item]); + + // Update with new title and content + const updatedItem: FeedItem = { + ...item, + title: 'Updated Title', + content: 'Updated content', + summary: 'Updated summary', + }; + + await storage.save([updatedItem]); + + const recent = await storage.getRecent(1); + expect(recent).toHaveLength(1); + expect(recent[0].title).toBe('Updated Title'); + expect(recent[0].content).toBe('Updated content'); + }); + + it('preserves other fields during upsert', async () => { + const item: FeedItem = { + id: 'preserved-id', + source: 'https://example.com/feed.xml', + title: 'Title', + url: 'https://example.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + summary: 'Summary', + content: 'Content', + }; + + await storage.save([item]); + + // Update only title + const updatedItem: FeedItem = { + ...item, + title: 'New Title', + }; + + await storage.save([updatedItem]); + + const recent = await storage.getRecent(1); + expect(recent[0].url).toBe('https://example.com/article'); // unchanged + expect(recent[0].publishedAt).toEqual(new Date('2024-09-06T09:00:00Z')); // unchanged + }); + }); + + describe('getRecent', () => { + it('returns items ordered by published date desc', async () => { + const items: FeedItem[] = [ + { + id: 'old', + source: 'https://example.com/feed.xml', + title: 'Old Article', + url: 'https://example.com/old', + publishedAt: new Date('2024-09-05T09:00:00Z'), + }, + { + id: 'new', + source: 'https://example.com/feed.xml', + title: 'New Article', + url: 'https://example.com/new', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await storage.save(items); + + const recent = await storage.getRecent(10); + expect(recent[0].title).toBe('New Article'); + expect(recent[1].title).toBe('Old Article'); + }); + + it('respects limit parameter', async () => { + const items: FeedItem[] = Array.from({ length: 5 }, (_, i) => ({ + id: `item${i}`, + source: 'https://example.com/feed.xml', + title: `Article ${i}`, + url: `https://example.com/${i}`, + publishedAt: new Date(`2024-09-0${i + 1}T09:00:00Z`), + })); + + await storage.save(items); + + const recent = await storage.getRecent(2); + expect(recent).toHaveLength(2); + }); + + it('returns empty array when no items', async () => { + const recent = await storage.getRecent(10); + expect(recent).toHaveLength(0); + }); + }); + + describe('getBySource', () => { + it('returns items filtered by source', async () => { + const items: FeedItem[] = [ + { + id: 'source1-item', + source: 'https://source1.com/feed.xml', + title: 'Source 1 Article', + url: 'https://source1.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + { + id: 'source2-item', + source: 'https://source2.com/feed.xml', + title: 'Source 2 Article', + url: 'https://source2.com/article', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }, + ]; + + await storage.save(items); + + const source1Items = await storage.getBySource('https://source1.com/feed.xml', 10); + expect(source1Items).toHaveLength(1); + expect(source1Items[0].title).toBe('Source 1 Article'); + }); + + it('respects limit parameter', async () => { + const items: FeedItem[] = Array.from({ length: 5 }, (_, i) => ({ + id: `item${i}`, + source: 'https://example.com/feed.xml', + title: `Article ${i}`, + url: `https://example.com/${i}`, + publishedAt: new Date(`2024-09-0${i + 1}T09:00:00Z`), + })); + + await storage.save(items); + + const sourceItems = await storage.getBySource('https://example.com/feed.xml', 2); + expect(sourceItems).toHaveLength(2); + }); + + it('returns empty array when no matching source', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article', + url: 'https://example.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await storage.save(items); + + const result = await storage.getBySource('https://other.com/feed.xml', 10); + expect(result).toHaveLength(0); + }); + }); + + describe('search', () => { + it('finds items by title', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'JavaScript Tutorial', + url: 'https://example.com/js', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Python Guide', + url: 'https://example.com/py', + publishedAt: new Date('2024-09-06T10:00:00Z'), + }, + ]; + + await storage.save(items); + + const results = await storage.search('JavaScript'); + expect(results).toHaveLength(1); + expect(results[0].title).toBe('JavaScript Tutorial'); + }); + + it('finds items by summary', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article One', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + summary: 'Learn about async programming', + }, + { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Article Two', + url: 'https://example.com/2', + publishedAt: new Date('2024-09-06T10:00:00Z'), + summary: 'Introduction to types', + }, + ]; + + await storage.save(items); + + const results = await storage.search('async'); + expect(results).toHaveLength(1); + expect(results[0].title).toBe('Article One'); + }); + + it('finds items by content', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article One', + url: 'https://example.com/1', + publishedAt: new Date('2024-09-06T09:00:00Z'), + content: 'Detailed explanation of closures', + }, + { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Article Two', + url: 'https://example.com/2', + publishedAt: new Date('2024-09-06T10:00:00Z'), + content: 'Overview of interfaces', + }, + ]; + + await storage.save(items); + + const results = await storage.search('closures'); + expect(results).toHaveLength(1); + expect(results[0].title).toBe('Article One'); + }); + + it('returns multiple matching items', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'React Tutorial', + url: 'https://example.com/react', + publishedAt: new Date('2024-09-05T09:00:00Z'), + }, + { + id: 'item2', + source: 'https://example.com/feed.xml', + title: 'Vue Guide', + url: 'https://example.com/vue', + publishedAt: new Date('2024-09-06T10:00:00Z'), + summary: 'A framework tutorial', + }, + ]; + + await storage.save(items); + + const results = await storage.search('Tutorial'); + expect(results).toHaveLength(2); + }); + + it('returns empty array when no matches', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'Article', + url: 'https://example.com/article', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await storage.save(items); + + const results = await storage.search('nonexistent'); + expect(results).toHaveLength(0); + }); + + it('performs case-sensitive search (as per SQL LIKE)', async () => { + const items: FeedItem[] = [ + { + id: 'item1', + source: 'https://example.com/feed.xml', + title: 'JavaScript', + url: 'https://example.com/js', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }, + ]; + + await storage.save(items); + + // SQLite LIKE is case-insensitive by default for ASCII + const results = await storage.search('javascript'); + expect(results).toHaveLength(1); + }); + }); + + describe('FeedItem properties preservation', () => { + it('preserves all FeedItem fields', async () => { + const item: FeedItem = { + id: 'full-item', + source: 'https://example.com/feed.xml', + title: 'Complete Article', + url: 'https://example.com/complete', + publishedAt: new Date('2024-09-06T09:30:00Z'), + summary: 'A brief summary', + content: 'The full article content here', + }; + + await storage.save([item]); + + const recent = await storage.getRecent(1); + const retrieved = recent[0]; + + expect(retrieved.id).toBe(item.id); + expect(retrieved.source).toBe(item.source); + expect(retrieved.title).toBe(item.title); + expect(retrieved.url).toBe(item.url); + expect(retrieved.publishedAt).toEqual(item.publishedAt); + expect(retrieved.summary).toBe(item.summary); + expect(retrieved.content).toBe(item.content); + }); + + it('handles items without optional fields', async () => { + const item: FeedItem = { + id: 'minimal-item', + source: 'https://example.com/feed.xml', + title: 'Minimal Article', + url: 'https://example.com/minimal', + publishedAt: new Date('2024-09-06T09:00:00Z'), + }; + + await storage.save([item]); + + const recent = await storage.getRecent(1); + expect(recent[0].summary).toBeUndefined(); + expect(recent[0].content).toBeUndefined(); + }); + }); +}); diff --git a/modules/storage/storage.ts b/modules/storage/storage.ts new file mode 100644 index 0000000..0769b00 --- /dev/null +++ b/modules/storage/storage.ts @@ -0,0 +1,101 @@ +/** + * Storage module implementation. + * Persists FeedItems to database using Kysely. + */ + +import type { Kysely } from 'kysely'; +import type { FeedItem } from '../../interfaces/feed.types.js'; +import type { IStorage } from '../../interfaces/storage.interface.js'; +import type { Database, FeedItemTable } from '../../infrastructure/db/database.js'; + +export class SqlStorage implements IStorage { + private readonly db: Kysely; + + constructor(db: Kysely) { + this.db = db; + } + + async save(items: FeedItem[]): Promise { + if (items.length === 0) { + return; + } + + const rows: FeedItemTable[] = items.map((item) => ({ + id: item.id, + source: item.source, + title: item.title, + url: item.url, + published_at: item.publishedAt.toISOString(), + content: item.content ?? null, + summary: item.summary ?? null, + created_at: new Date().toISOString(), + })); + + // Upsert: insert or update on conflict + await this.db + .insertInto('feed_items') + .values(rows) + .onConflict((oc) => + oc.column('id').doUpdateSet({ + title: (eb) => eb.ref('excluded.title'), + content: (eb) => eb.ref('excluded.content'), + summary: (eb) => eb.ref('excluded.summary'), + }) + ) + .execute(); + } + + async getRecent(limit: number): Promise { + const rows = await this.db + .selectFrom('feed_items') + .selectAll() + .orderBy('published_at', 'desc') + .limit(limit) + .execute(); + + return rows.map(this.rowToFeedItem); + } + + async getBySource(source: string, limit: number): Promise { + const rows = await this.db + .selectFrom('feed_items') + .selectAll() + .where('source', '=', source) + .orderBy('published_at', 'desc') + .limit(limit) + .execute(); + + return rows.map(this.rowToFeedItem); + } + + async search(query: string): Promise { + const searchTerm = `%${query}%`; + + const rows = await this.db + .selectFrom('feed_items') + .selectAll() + .where((eb) => + eb.or([ + eb('title', 'like', searchTerm), + eb('summary', 'like', searchTerm), + eb('content', 'like', searchTerm), + ]) + ) + .orderBy('published_at', 'desc') + .execute(); + + return rows.map(this.rowToFeedItem); + } + + private rowToFeedItem(row: FeedItemTable): FeedItem { + return { + id: row.id, + source: row.source, + title: row.title, + url: row.url, + publishedAt: new Date(row.published_at), + content: row.content ?? undefined, + summary: row.summary ?? undefined, + }; + } +} diff --git a/package-lock.json b/package-lock.json index 97e1bdc..46d42cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,16 @@ "name": "pulse", "version": "0.1.0", "dependencies": { + "better-sqlite3": "^12.9.0", "fast-xml-parser": "^5.7.3", + "kysely": "^0.28.17", + "pg": "^8.20.0", "undici": "^6.21.0" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^2.1.0" @@ -829,6 +834,16 @@ "win32" ] }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -846,6 +861,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -969,6 +996,84 @@ "node": ">=12" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1006,6 +1111,12 @@ "node": ">= 16" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1024,6 +1135,21 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1034,6 +1160,33 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1093,6 +1246,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1139,6 +1301,18 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1167,6 +1341,53 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1184,6 +1405,33 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1210,6 +1458,33 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.90.0.tgz", + "integrity": "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -1242,6 +1517,95 @@ "node": ">= 14.16" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1278,6 +1642,111 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1333,6 +1802,38 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1340,6 +1841,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1350,6 +1896,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1364,6 +1919,24 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strnum": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", @@ -1376,6 +1949,34 @@ ], "license": "MIT" }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1440,6 +2041,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1470,6 +2083,12 @@ "dev": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -2065,6 +2684,21 @@ "engines": { "node": ">=8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/package.json b/package.json index d54be99..e660f04 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,18 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^2.1.0" }, "dependencies": { + "better-sqlite3": "^12.9.0", "fast-xml-parser": "^5.7.3", + "kysely": "^0.28.17", + "pg": "^8.20.0", "undici": "^6.21.0" } }