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:
parent
2ea1f5cd95
commit
40ccbbad1a
52
.env.example
Normal file
52
.env.example
Normal 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
|
||||||
91
infrastructure/db/config.ts
Normal file
91
infrastructure/db/config.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
infrastructure/db/connection.ts
Normal file
58
infrastructure/db/connection.ts
Normal 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}`);
|
||||||
|
}
|
||||||
26
infrastructure/db/database.ts
Normal file
26
infrastructure/db/database.ts
Normal 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;
|
||||||
|
}
|
||||||
35
infrastructure/db/index.ts
Normal file
35
infrastructure/db/index.ts
Normal 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);
|
||||||
|
}
|
||||||
54
infrastructure/db/schema.ts
Normal file
54
infrastructure/db/schema.ts
Normal 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
400
modules/dedup/dedup.test.ts
Normal 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
55
modules/dedup/dedup.ts
Normal 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
1
modules/dedup/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { DatabaseDedup } from './dedup.js';
|
||||||
1
modules/storage/index.ts
Normal file
1
modules/storage/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { SqlStorage } from './storage.js';
|
||||||
430
modules/storage/storage.test.ts
Normal file
430
modules/storage/storage.test.ts
Normal 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
101
modules/storage/storage.ts
Normal 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
634
package-lock.json
generated
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user