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
This commit is contained in:
Edo Limburg 2026-05-05 21:59:50 +02:00
parent 2ea1f5cd95
commit 40ccbbad1a
14 changed files with 1943 additions and 0 deletions

52
.env.example Normal file
View File

@ -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

View File

@ -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');
}
}
}

View File

@ -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<Database> {
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<Database>({
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<Database>({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: config.postgres.connectionString,
}),
}),
});
} else {
return new Kysely<Database>({
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}`);
}

View File

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

View File

@ -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<Database> {
const config = loadConfig();
validateConfig(config);
return createDatabase(config);
}
/**
* Get a database instance with explicit config (for testing).
*/
export function getDatabaseWithConfig(config: DatabaseConfig): Kysely<Database> {
validateConfig(config);
return createDatabase(config);
}

View File

@ -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<Database>): Promise<void> {
// 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<Database>): Promise<void> {
// 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();
}

400
modules/dedup/dedup.test.ts Normal file
View File

@ -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<Database>;
let dedup: DatabaseDedup;
beforeEach(async () => {
// Create in-memory database for each test
sqliteDb = new BetterSqlite3(':memory:');
sqliteDb.pragma('journal_mode = WAL');
db = new Kysely<Database>({
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);
});
});
});

55
modules/dedup/dedup.ts Normal file
View File

@ -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<Database>;
constructor(db: Kysely<Database>) {
this.db = db;
}
async filter(items: FeedItem[]): Promise<FeedItem[]> {
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<void> {
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();
}
}

1
modules/dedup/index.ts Normal file
View File

@ -0,0 +1 @@
export { DatabaseDedup } from './dedup.js';

1
modules/storage/index.ts Normal file
View File

@ -0,0 +1 @@
export { SqlStorage } from './storage.js';

View File

@ -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<Database>;
let storage: SqlStorage;
beforeEach(async () => {
// Create in-memory database for each test
sqliteDb = new BetterSqlite3(':memory:');
sqliteDb.pragma('journal_mode = WAL');
db = new Kysely<Database>({
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();
});
});
});

101
modules/storage/storage.ts Normal file
View File

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

634
package-lock.json generated
View File

@ -8,11 +8,16 @@
"name": "pulse", "name": "pulse",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.9.0",
"fast-xml-parser": "^5.7.3", "fast-xml-parser": "^5.7.3",
"kysely": "^0.28.17",
"pg": "^8.20.0",
"undici": "^6.21.0" "undici": "^6.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/pg": "^8.20.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"
@ -829,6 +834,16 @@
"win32" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -846,6 +861,18 @@
"undici-types": "~6.21.0" "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": { "node_modules/@vitest/expect": {
"version": "2.1.9", "version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
@ -969,6 +996,84 @@
"node": ">=12" "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": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -1006,6 +1111,12 @@
"node": ">= 16" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/deep-eql": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@ -1034,6 +1160,33 @@
"node": ">=6" "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": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@ -1093,6 +1246,15 @@
"@types/estree": "^1.0.0" "@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": { "node_modules/expect-type": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@ -1139,6 +1301,18 @@
"fxparser": "src/cli/cli.js" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1167,6 +1341,53 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "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": { "node_modules/loupe": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@ -1184,6 +1405,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "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": "^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": { "node_modules/path-expression-matcher": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
@ -1242,6 +1517,95 @@
"node": ">= 14.16" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1278,6 +1642,111 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@ -1333,6 +1802,38 @@
"fsevents": "~2.3.2" "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": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@ -1340,6 +1841,51 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1350,6 +1896,15 @@
"node": ">=0.10.0" "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": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -1364,6 +1919,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/strnum": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
@ -1376,6 +1949,34 @@
], ],
"license": "MIT" "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": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -1440,6 +2041,18 @@
"fsevents": "~2.3.3" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -1470,6 +2083,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@ -2065,6 +2684,21 @@
"engines": { "engines": {
"node": ">=8" "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"
}
} }
} }
} }

View File

@ -8,13 +8,18 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/pg": "^8.20.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^2.1.0" "vitest": "^2.1.0"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.9.0",
"fast-xml-parser": "^5.7.3", "fast-xml-parser": "^5.7.3",
"kysely": "^0.28.17",
"pg": "^8.20.0",
"undici": "^6.21.0" "undici": "^6.21.0"
} }
} }