- Add storage module with SQLite persistence via better-sqlite3 - Add deduplication module for feed item dedup - Add infrastructure directory for deployment config - Add .env.example for environment variables - Update dependencies: kysely, better-sqlite3, pg
102 lines
2.6 KiB
TypeScript
102 lines
2.6 KiB
TypeScript
/**
|
|
* 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<Database>;
|
|
|
|
constructor(db: Kysely<Database>) {
|
|
this.db = db;
|
|
}
|
|
|
|
async save(items: FeedItem[]): Promise<void> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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<FeedItem[]> {
|
|
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,
|
|
};
|
|
}
|
|
}
|