pulse/modules/storage/storage.ts
Edo Limburg 40ccbbad1a Add storage, dedup modules and infrastructure configuration
- 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
2026-05-05 21:59:50 +02:00

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,
};
}
}