diff --git a/README.md b/README.md index 1aff1a4..34708a5 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,68 @@ const config = { }; ``` +#### PostgreSQL Schema Configuration + +Nuvex uses strongly branded table and column names by default (`nuvex_storage`, `nuvex_key`, `nuvex_data`). For backwards compatibility with existing applications, you can customize these names: + +```typescript +// Default configuration (recommended for new apps) +const storage = await NuvexClient.initialize({ + postgres: { + host: 'localhost', + port: 5432, + database: 'myapp', + user: 'postgres', + password: 'password' + // Uses: table 'nuvex_storage', columns 'nuvex_key' and 'nuvex_data' + } +}); + +// Custom configuration for Telegram bot compatibility +const storage = await NuvexClient.initialize({ + postgres: { + host: 'localhost', + port: 5432, + database: 'telegram_bot', + user: 'postgres', + password: 'password', + schema: { + tableName: 'storage_cache', + columns: { + key: 'key', + value: 'value' + } + } + } +}); + +// Custom configuration for Discord bot compatibility +const storage = await NuvexClient.initialize({ + postgres: { + host: 'localhost', + port: 5432, + database: 'discord_bot', + user: 'postgres', + password: 'password', + schema: { + tableName: 'storage_cache', + columns: { + key: 'cache_key', + value: 'data' + } + } + } +}); +``` + +**Schema Configuration Reference:** + +| App | Table | Key Column | Data Column | +|---------------|-----------------|---------------|---------------| +| **Nuvex** (default) | nuvex_storage | nuvex_key | nuvex_data | +| Telegram Bot | storage_cache | key | value | +| Discord Bot | storage_cache | cache_key | data | + ### API Reference ```typescript diff --git a/src/__tests__/unit/database.test.ts b/src/__tests__/unit/database.test.ts index 1e34794..e8a99a0 100644 --- a/src/__tests__/unit/database.test.ts +++ b/src/__tests__/unit/database.test.ts @@ -30,8 +30,8 @@ describe('Database Utilities', () => { it('should contain the complete schema definition', () => { expect(NUVEX_SCHEMA_SQL).toContain('CREATE TABLE IF NOT EXISTS nuvex_storage'); expect(NUVEX_SCHEMA_SQL).toContain('id SERIAL PRIMARY KEY'); - expect(NUVEX_SCHEMA_SQL).toContain('key VARCHAR(512) NOT NULL UNIQUE'); - expect(NUVEX_SCHEMA_SQL).toContain('value JSONB NOT NULL'); + expect(NUVEX_SCHEMA_SQL).toContain('nuvex_key VARCHAR(512) NOT NULL UNIQUE'); + expect(NUVEX_SCHEMA_SQL).toContain('nuvex_data JSONB NOT NULL'); expect(NUVEX_SCHEMA_SQL).toContain('expires_at TIMESTAMP WITH TIME ZONE'); expect(NUVEX_SCHEMA_SQL).toContain('idx_nuvex_storage_expires_at'); expect(NUVEX_SCHEMA_SQL).toContain('update_nuvex_storage_updated_at'); @@ -103,6 +103,73 @@ describe('Database Utilities', () => { expect.arrayContaining([expect.stringMatching(/nuvex-cleanup-\d+/)]) ); }); + + it('should setup schema with custom table name', async () => { + const querySpy = jest.spyOn(mockDb, 'query'); + const options: SchemaSetupOptions = { + schema: { + tableName: 'storage_cache' + } + }; + + await setupNuvexSchema(mockDb, options); + + // Should call query with custom schema SQL + const queryCalls = querySpy.mock.calls; + const schemaCall = queryCalls.find((call: any[]) => + typeof call[0] === 'string' && call[0].includes('CREATE TABLE IF NOT EXISTS storage_cache') + ); + expect(schemaCall).toBeDefined(); + expect(console.log).toHaveBeenCalledWith('Nuvex database schema setup completed successfully'); + }); + + it('should setup schema with custom column names', async () => { + const querySpy = jest.spyOn(mockDb, 'query'); + const options: SchemaSetupOptions = { + schema: { + columns: { + key: 'cache_key', + value: 'data' + } + } + }; + + await setupNuvexSchema(mockDb, options); + + // Should call query with custom column names + const queryCalls = querySpy.mock.calls; + const schemaCall = queryCalls.find((call: any[]) => + typeof call[0] === 'string' && + call[0].includes('cache_key VARCHAR(512)') && + call[0].includes('data JSONB') + ); + expect(schemaCall).toBeDefined(); + }); + + it('should setup schema with custom table and column names for Telegram bot', async () => { + const querySpy = jest.spyOn(mockDb, 'query'); + const options: SchemaSetupOptions = { + schema: { + tableName: 'storage_cache', + columns: { + key: 'key', + value: 'value' + } + } + }; + + await setupNuvexSchema(mockDb, options); + + // Should call query with custom schema SQL + const queryCalls = querySpy.mock.calls; + const schemaCall = queryCalls.find((call: any[]) => + typeof call[0] === 'string' && + call[0].includes('CREATE TABLE IF NOT EXISTS storage_cache') && + call[0].includes('key VARCHAR(512)') && + call[0].includes('value JSONB') + ); + expect(schemaCall).toBeDefined(); + }); }); describe('cleanupExpiredEntries', () => { @@ -141,6 +208,24 @@ describe('Database Utilities', () => { await expect(cleanupExpiredEntries(mockDb)).rejects.toThrow('Cleanup failed'); expect(console.error).toHaveBeenCalledWith('Failed to cleanup expired entries:', error); }); + + it('should use custom table name when provided', async () => { + const mockResult = { rows: [{ deleted_count: 3 }] }; + jest.spyOn(mockDb, 'query').mockResolvedValue(mockResult); + + const deletedCount = await cleanupExpiredEntries(mockDb, { + tableName: 'storage_cache' + }); + + expect(deletedCount).toBe(3); + expect(mockDb.query).toHaveBeenCalledWith('SELECT cleanup_expired_storage_cache() as deleted_count;'); + }); + + it('should validate custom table name', async () => { + await expect(cleanupExpiredEntries(mockDb, { + tableName: 'invalid; DROP TABLE users; --' + })).rejects.toThrow('Invalid table name'); + }); }); describe('dropNuvexSchema', () => { @@ -162,5 +247,26 @@ describe('Database Utilities', () => { await expect(dropNuvexSchema(mockDb)).rejects.toThrow('Drop schema failed'); expect(console.error).toHaveBeenCalledWith('Failed to drop Nuvex database schema:', error); }); + + it('should drop custom table schema', async () => { + const querySpy = jest.spyOn(mockDb, 'query'); + + await dropNuvexSchema(mockDb, { + tableName: 'storage_cache' + }); + + // Should use custom table name in drop statements + const queryCall = querySpy.mock.calls[0][0] as string; + expect(queryCall).toContain('DROP TRIGGER IF EXISTS trigger_update_storage_cache_updated_at ON storage_cache'); + expect(queryCall).toContain('DROP FUNCTION IF EXISTS update_storage_cache_updated_at()'); + expect(queryCall).toContain('DROP FUNCTION IF EXISTS cleanup_expired_storage_cache()'); + expect(queryCall).toContain('DROP TABLE IF EXISTS storage_cache CASCADE'); + }); + + it('should validate custom table name before dropping', async () => { + await expect(dropNuvexSchema(mockDb, { + tableName: 'invalid; DROP TABLE users; --' + })).rejects.toThrow('Invalid table name'); + }); }); }); diff --git a/src/__tests__/unit/postgres-schema.test.ts b/src/__tests__/unit/postgres-schema.test.ts new file mode 100644 index 0000000..90a06b7 --- /dev/null +++ b/src/__tests__/unit/postgres-schema.test.ts @@ -0,0 +1,398 @@ +/** + * PostgreSQL Configurable Schema Tests + * Tests for custom table and column name configuration + */ + +import { PostgresStorage } from '../../layers/postgres'; +import { generateNuvexSchemaSQL } from '../../core/database'; +import type { PostgresSchemaConfig } from '../../types/index'; + +describe('PostgreSQL Configurable Schema', () => { + describe('generateNuvexSchemaSQL', () => { + it('should generate default schema SQL', () => { + const sql = generateNuvexSchemaSQL(); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS nuvex_storage'); + expect(sql).toContain('nuvex_key VARCHAR(512) NOT NULL UNIQUE'); + expect(sql).toContain('nuvex_data JSONB NOT NULL'); + expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_nuvex_storage_expires_at'); + expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_nuvex_storage_key_pattern'); + expect(sql).toContain('CREATE OR REPLACE FUNCTION update_nuvex_storage_updated_at()'); + expect(sql).toContain('CREATE OR REPLACE FUNCTION cleanup_expired_nuvex_storage()'); + }); + + it('should reject invalid table names with SQL injection attempts', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage_cache; DROP TABLE users; --', + columns: { + key: 'key', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid table name'); + }); + + it('should reject invalid column names with SQL injection attempts', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage_cache', + columns: { + key: 'key\' OR 1=1 --', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid key column name'); + }); + + it('should accept valid identifiers with underscores and numbers', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage_cache_v2', + columns: { + key: 'cache_key_123', + value: 'data_value_v1' + } + }; + + const sql = generateNuvexSchemaSQL(schema); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS storage_cache_v2'); + expect(sql).toContain('cache_key_123 VARCHAR(512) NOT NULL UNIQUE'); + expect(sql).toContain('data_value_v1 JSONB NOT NULL'); + }); + + it('should reject empty string as table name', () => { + const schema: PostgresSchemaConfig = { + tableName: '', + columns: { + key: 'key', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid table name'); + }); + + it('should reject empty string as column name', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage_cache', + columns: { + key: '', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid key column name'); + }); + + it('should reject identifier starting with number', () => { + const schema: PostgresSchemaConfig = { + tableName: '123_storage', + columns: { + key: 'key', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid table name'); + }); + + it('should reject column name starting with number', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage_cache', + columns: { + key: '1key', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid key column name'); + }); + + it('should reject identifier with only special characters', () => { + const schema: PostgresSchemaConfig = { + tableName: '!!!', + columns: { + key: 'key', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid table name'); + }); + + it('should reject identifier with spaces', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage cache', + columns: { + key: 'key', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid table name'); + }); + + it('should reject identifier with hyphens', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage-cache', + columns: { + key: 'key', + value: 'value' + } + }; + + expect(() => generateNuvexSchemaSQL(schema)).toThrow('Invalid table name'); + }); + + it('should accept identifier starting with underscore', () => { + const schema: PostgresSchemaConfig = { + tableName: '_storage_cache', + columns: { + key: '_key', + value: '_value' + } + }; + + const sql = generateNuvexSchemaSQL(schema); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS _storage_cache'); + expect(sql).toContain('_key VARCHAR(512) NOT NULL UNIQUE'); + expect(sql).toContain('_value JSONB NOT NULL'); + }); + + it('should generate custom schema SQL for Telegram bot', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage_cache', + columns: { + key: 'key', + value: 'value' + } + }; + + const sql = generateNuvexSchemaSQL(schema); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS storage_cache'); + expect(sql).toContain('key VARCHAR(512) NOT NULL UNIQUE'); + expect(sql).toContain('value JSONB NOT NULL'); + expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_storage_cache_expires_at'); + expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_storage_cache_key_pattern'); + expect(sql).toContain('CREATE OR REPLACE FUNCTION update_storage_cache_updated_at()'); + expect(sql).toContain('CREATE OR REPLACE FUNCTION cleanup_expired_storage_cache()'); + }); + + it('should generate custom schema SQL for Discord bot', () => { + const schema: PostgresSchemaConfig = { + tableName: 'storage_cache', + columns: { + key: 'cache_key', + value: 'data' + } + }; + + const sql = generateNuvexSchemaSQL(schema); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS storage_cache'); + expect(sql).toContain('cache_key VARCHAR(512) NOT NULL UNIQUE'); + expect(sql).toContain('data JSONB NOT NULL'); + expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_storage_cache_expires_at'); + expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_storage_cache_key_pattern'); + expect(sql).toContain('CREATE OR REPLACE FUNCTION update_storage_cache_updated_at()'); + expect(sql).toContain('CREATE OR REPLACE FUNCTION cleanup_expired_storage_cache()'); + }); + + it('should support partial schema configuration', () => { + const schema: PostgresSchemaConfig = { + tableName: 'my_storage' + }; + + const sql = generateNuvexSchemaSQL(schema); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS my_storage'); + expect(sql).toContain('nuvex_key VARCHAR(512) NOT NULL UNIQUE'); + expect(sql).toContain('nuvex_data JSONB NOT NULL'); + }); + + it('should support column-only configuration', () => { + const schema: PostgresSchemaConfig = { + columns: { + key: 'my_key', + value: 'my_value' + } + }; + + const sql = generateNuvexSchemaSQL(schema); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS nuvex_storage'); + expect(sql).toContain('my_key VARCHAR(512) NOT NULL UNIQUE'); + expect(sql).toContain('my_value JSONB NOT NULL'); + }); + }); + + describe('PostgresStorage with custom schema', () => { + // Mock the pg.Pool constructor + let PoolMock: jest.Mock; + + beforeEach(() => { + // Create a mock Pool constructor + PoolMock = jest.fn().mockImplementation(() => ({ + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connect: jest.fn().mockResolvedValue({ + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: jest.fn() + }), + end: jest.fn() + })); + }); + + it('should use default table and column names', async () => { + const mockPool = { + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connect: jest.fn().mockResolvedValue({ + query: jest.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: jest.fn() + }), + end: jest.fn() + }; + + const storage = new PostgresStorage(mockPool); + await storage.connect(); + + // Test get operation + await storage.get('test-key'); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('FROM nuvex_storage'), + expect.any(Array) + ); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('WHERE nuvex_key = $1'), + expect.any(Array) + ); + }); + + it('should use custom table name from schema config (Telegram bot)', async () => { + const config = { + host: 'localhost', + port: 5432, + database: 'test', + user: 'test', + password: 'test', + schema: { + tableName: 'storage_cache' + } + }; + + // Create storage instance + const storage = new PostgresStorage(config); + + // Access private members to verify schema configuration + expect((storage as any).tableName).toBe('storage_cache'); + expect((storage as any).keyColumn).toBe('nuvex_key'); + expect((storage as any).valueColumn).toBe('nuvex_data'); + }); + + it('should use custom column names from schema config', async () => { + const config = { + host: 'localhost', + port: 5432, + database: 'test', + user: 'test', + password: 'test', + schema: { + columns: { + key: 'cache_key', + value: 'data' + } + } + }; + + const storage = new PostgresStorage(config); + + // Access private members to verify schema configuration + expect((storage as any).tableName).toBe('nuvex_storage'); + expect((storage as any).keyColumn).toBe('cache_key'); + expect((storage as any).valueColumn).toBe('data'); + }); + + it('should use custom table and column names for Telegram bot', async () => { + const config = { + host: 'localhost', + port: 5432, + database: 'test', + user: 'test', + password: 'test', + schema: { + tableName: 'storage_cache', + columns: { + key: 'key', + value: 'value' + } + } + }; + + const storage = new PostgresStorage(config); + + // Access private members to verify schema configuration + expect((storage as any).tableName).toBe('storage_cache'); + expect((storage as any).keyColumn).toBe('key'); + expect((storage as any).valueColumn).toBe('value'); + }); + + it('should use custom table and column names for Discord bot', async () => { + const config = { + host: 'localhost', + port: 5432, + database: 'test', + user: 'test', + password: 'test', + schema: { + tableName: 'storage_cache', + columns: { + key: 'cache_key', + value: 'data' + } + } + }; + + const storage = new PostgresStorage(config); + + // Access private members to verify schema configuration + expect((storage as any).tableName).toBe('storage_cache'); + expect((storage as any).keyColumn).toBe('cache_key'); + expect((storage as any).valueColumn).toBe('data'); + }); + + it('should reject invalid table name in constructor', () => { + const config = { + host: 'localhost', + port: 5432, + database: 'test', + user: 'test', + password: 'test', + schema: { + tableName: 'storage; DROP TABLE users; --' + } + }; + + expect(() => new PostgresStorage(config)).toThrow('Invalid table name'); + }); + + it('should reject invalid column name in constructor', () => { + const config = { + host: 'localhost', + port: 5432, + database: 'test', + user: 'test', + password: 'test', + schema: { + columns: { + key: 'key\' OR 1=1 --' + } + } + }; + + expect(() => new PostgresStorage(config)).toThrow('Invalid key column name'); + }); + }); +}); diff --git a/src/core/database.ts b/src/core/database.ts index 23346bb..1a679b6 100644 --- a/src/core/database.ts +++ b/src/core/database.ts @@ -10,32 +10,74 @@ */ import type { Pool as PoolType } from 'pg'; +import type { PostgresSchemaConfig } from '../types/index.js'; -export const NUVEX_SCHEMA_SQL = ` +/** + * Validate SQL identifier to prevent SQL injection + * Ensures the identifier contains only alphanumeric characters and underscores + * + * @param identifier - SQL identifier to validate + * @param name - Name of the identifier for error messages + * @throws {Error} If identifier contains invalid characters + * + * @example + * ```typescript + * validateSQLIdentifier('my_table_123', 'table name'); // OK + * validateSQLIdentifier('users; DROP TABLE', 'table name'); // throws Error + * ``` + * + * @since 1.0.0 + */ +export function validateSQLIdentifier(identifier: string, name: string): void { + if (!identifier || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) { + throw new Error( + `Invalid ${name}: "${identifier}". SQL identifiers must start with a letter or underscore and contain only alphanumeric characters and underscores.` + ); + } +} + +/** + * Generate SQL schema for Nuvex storage with configurable table and column names + * + * @param schema - Optional schema configuration for custom table/column names + * @returns SQL string for creating the schema + * @throws {Error} If table or column names contain invalid characters + */ +export function generateNuvexSchemaSQL(schema?: PostgresSchemaConfig): string { + const tableName = schema?.tableName ?? 'nuvex_storage'; + const keyColumn = schema?.columns?.key ?? 'nuvex_key'; + const valueColumn = schema?.columns?.value ?? 'nuvex_data'; + + // Validate all identifiers to prevent SQL injection + validateSQLIdentifier(tableName, 'table name'); + validateSQLIdentifier(keyColumn, 'key column name'); + validateSQLIdentifier(valueColumn, 'value column name'); + + return ` -- Nuvex storage table for PostgreSQL layer -CREATE TABLE IF NOT EXISTS nuvex_storage ( +CREATE TABLE IF NOT EXISTS ${tableName} ( id SERIAL PRIMARY KEY, - key VARCHAR(512) NOT NULL UNIQUE, - value JSONB NOT NULL, + ${keyColumn} VARCHAR(512) NOT NULL UNIQUE, + ${valueColumn} JSONB NOT NULL, expires_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Index for expiration cleanup -CREATE INDEX IF NOT EXISTS idx_nuvex_storage_expires_at -ON nuvex_storage(expires_at) +CREATE INDEX IF NOT EXISTS idx_${tableName}_expires_at +ON ${tableName}(expires_at) WHERE expires_at IS NOT NULL; -- Ensure pg_trgm extension is available for GIN index CREATE EXTENSION IF NOT EXISTS pg_trgm; -- Index for key pattern searches -CREATE INDEX IF NOT EXISTS idx_nuvex_storage_key_pattern -ON nuvex_storage USING gin(key gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_${tableName}_key_pattern +ON ${tableName} USING gin(${keyColumn} gin_trgm_ops); -- Function to auto-update updated_at -CREATE OR REPLACE FUNCTION update_nuvex_storage_updated_at() +CREATE OR REPLACE FUNCTION update_${tableName}_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); @@ -44,19 +86,19 @@ END; $$ LANGUAGE plpgsql; -- Trigger for auto-updating updated_at -DROP TRIGGER IF EXISTS trigger_update_nuvex_storage_updated_at ON nuvex_storage; -CREATE TRIGGER trigger_update_nuvex_storage_updated_at - BEFORE UPDATE ON nuvex_storage +DROP TRIGGER IF EXISTS trigger_update_${tableName}_updated_at ON ${tableName}; +CREATE TRIGGER trigger_update_${tableName}_updated_at + BEFORE UPDATE ON ${tableName} FOR EACH ROW - EXECUTE FUNCTION update_nuvex_storage_updated_at(); + EXECUTE FUNCTION update_${tableName}_updated_at(); -- Function to clean up expired entries -CREATE OR REPLACE FUNCTION cleanup_expired_nuvex_storage() +CREATE OR REPLACE FUNCTION cleanup_expired_${tableName}() RETURNS INTEGER AS $$ DECLARE deleted_count INTEGER; BEGIN - DELETE FROM nuvex_storage + DELETE FROM ${tableName} WHERE expires_at IS NOT NULL AND expires_at < NOW(); GET DIAGNOSTICS deleted_count = ROW_COUNT; @@ -64,12 +106,17 @@ BEGIN END; $$ LANGUAGE plpgsql; `; +} + +export const NUVEX_SCHEMA_SQL = generateNuvexSchemaSQL(); export interface SchemaSetupOptions { /** Enable trigram extension for advanced pattern matching (requires pg_trgm) */ enableTrigram?: boolean; /** Set up periodic cleanup job using pg_cron extension */ - enableCleanupJob?: boolean; + enableCleanupJob?: boolean; + /** Schema configuration for custom table/column names */ + schema?: PostgresSchemaConfig; } /** @@ -122,12 +169,15 @@ export async function setupNuvexSchema( await db.query('CREATE EXTENSION IF NOT EXISTS pg_trgm;'); } + // Generate schema SQL with custom table/column names if provided + const schemaSQL = options.schema ? generateNuvexSchemaSQL(options.schema) : NUVEX_SCHEMA_SQL; + // Execute main schema - await db.query(NUVEX_SCHEMA_SQL); + await db.query(schemaSQL); // Setup periodic cleanup job if requested if (options.enableCleanupJob) { - await setupCleanupJob(db); + await setupCleanupJob(db, undefined, options.schema); } console.log('Nuvex database schema setup completed successfully'); @@ -161,8 +211,11 @@ export async function setupNuvexSchema( * @requires pg_cron extension and superuser privileges * @since 1.0.0 */ -async function setupCleanupJob(db: PoolType, tenantId?: string): Promise { +async function setupCleanupJob(db: PoolType, tenantId?: string, schema?: PostgresSchemaConfig): Promise { try { + const tableName = schema?.tableName || 'nuvex_storage'; + validateSQLIdentifier(tableName, 'table name'); + // Generate a unique job name per tenant/context const jobName = tenantId ? `nuvex-cleanup-${tenantId}` : `nuvex-cleanup-${Date.now()}`; // This requires pg_cron extension and superuser privileges @@ -170,7 +223,7 @@ async function setupCleanupJob(db: PoolType, tenantId?: string): Promise { SELECT cron.schedule( $1, '0 2 * * *', -- Daily at 2 AM - 'SELECT cleanup_expired_nuvex_storage();' + 'SELECT cleanup_expired_${tableName}();' ); `, [jobName]); console.log(`Nuvex cleanup cron job scheduled as '${jobName}'`); @@ -199,9 +252,12 @@ async function setupCleanupJob(db: PoolType, tenantId?: string): Promise { * * @since 1.0.0 */ -export async function cleanupExpiredEntries(db: PoolType): Promise { +export async function cleanupExpiredEntries(db: PoolType, schema?: PostgresSchemaConfig): Promise { try { - const result = await db.query('SELECT cleanup_expired_nuvex_storage() as deleted_count;'); + const tableName = schema?.tableName || 'nuvex_storage'; + validateSQLIdentifier(tableName, 'table name'); + + const result = await db.query(`SELECT cleanup_expired_${tableName}() as deleted_count;`); return result.rows[0]?.deleted_count || 0; } catch (error) { console.error('Failed to cleanup expired entries:', error); @@ -230,13 +286,16 @@ export async function cleanupExpiredEntries(db: PoolType): Promise { * @warning This operation is irreversible and will cause permanent data loss * @since 1.0.0 */ -export async function dropNuvexSchema(db: PoolType): Promise { +export async function dropNuvexSchema(db: PoolType, schema?: PostgresSchemaConfig): Promise { try { + const tableName = schema?.tableName || 'nuvex_storage'; + validateSQLIdentifier(tableName, 'table name'); + await db.query(` - DROP TRIGGER IF EXISTS trigger_update_nuvex_storage_updated_at ON nuvex_storage; - DROP FUNCTION IF EXISTS update_nuvex_storage_updated_at(); - DROP FUNCTION IF EXISTS cleanup_expired_nuvex_storage(); - DROP TABLE IF EXISTS nuvex_storage CASCADE; + DROP TRIGGER IF EXISTS trigger_update_${tableName}_updated_at ON ${tableName}; + DROP FUNCTION IF EXISTS update_${tableName}_updated_at(); + DROP FUNCTION IF EXISTS cleanup_expired_${tableName}(); + DROP TABLE IF EXISTS ${tableName} CASCADE; `); console.log('Nuvex database schema dropped successfully'); } catch (error) { diff --git a/src/layers/postgres.ts b/src/layers/postgres.ts index 5cc0bd5..8bd9c28 100644 --- a/src/layers/postgres.ts +++ b/src/layers/postgres.ts @@ -22,6 +22,7 @@ import pkg from 'pg'; const { Pool } = pkg; import type { Pool as PoolType } from 'pg'; import type { StorageLayerInterface, Logger } from '../interfaces/index.js'; +import { validateSQLIdentifier } from '../core/database.js'; /** * PostgreSQL Storage Layer - L3 Persistent Storage @@ -98,26 +99,42 @@ export class PostgresStorage implements StorageLayerInterface { /** Whether we created the pool (vs. received existing one) */ private readonly ownsPool: boolean; + /** Table name for storage */ + private readonly tableName: string; + + /** Key column name */ + private readonly keyColumn: string; + + /** Value/data column name */ + private readonly valueColumn: string; + /** * Creates a new PostgresStorage instance * * Accepts either a PostgreSQL configuration object or an existing Pool instance. * If a Pool is provided, the caller is responsible for managing its lifecycle. * + * Schema configuration is extracted from the config object when creating a new pool. + * When using an existing pool, schema defaults to standard Nuvex naming. + * * @param config - PostgreSQL configuration or existing Pool instance * @param logger - Optional logger for debugging * * @example * ```typescript - * // With configuration + * // With configuration (supports schema customization) * const postgres = new PostgresStorage({ * host: 'localhost', * database: 'myapp', * user: 'postgres', - * password: 'password' + * password: 'password', + * schema: { + * tableName: 'storage_cache', + * columns: { key: 'key', value: 'value' } + * } * }); * - * // With existing pool + * // With existing pool (uses default schema) * const existingPool = new Pool({ ... }); * const postgres = new PostgresStorage(existingPool); * ``` @@ -131,6 +148,18 @@ export class PostgresStorage implements StorageLayerInterface { // Check if config is already a Pool instance this.ownsPool = !('query' in config && typeof config.query === 'function'); + + // Extract schema configuration with defaults + // Note: Schema is only extracted from config objects, not from existing Pool instances + const schema = this.ownsPool ? config.schema : undefined; + this.tableName = schema?.tableName ?? 'nuvex_storage'; + this.keyColumn = schema?.columns?.key ?? 'nuvex_key'; + this.valueColumn = schema?.columns?.value ?? 'nuvex_data'; + + // Validate all identifiers to prevent SQL injection + validateSQLIdentifier(this.tableName, 'table name'); + validateSQLIdentifier(this.keyColumn, 'key column name'); + validateSQLIdentifier(this.valueColumn, 'value column name'); } /** @@ -219,7 +248,7 @@ export class PostgresStorage implements StorageLayerInterface { try { const result = await this.pool.query( - 'SELECT value FROM nuvex_storage WHERE key = $1 AND (expires_at IS NULL OR expires_at > NOW())', + `SELECT ${this.valueColumn} FROM ${this.tableName} WHERE ${this.keyColumn} = $1 AND (expires_at IS NULL OR expires_at > NOW())`, [key] ); @@ -228,7 +257,7 @@ export class PostgresStorage implements StorageLayerInterface { } // Value is already parsed by PostgreSQL JSONB type - return result.rows[0].value; + return result.rows[0][this.valueColumn]; } catch (error) { // Table might not exist yet, that's okay this.log('debug', `PostgreSQL L3: Error getting key: ${key}`, { @@ -270,10 +299,10 @@ export class PostgresStorage implements StorageLayerInterface { const expiresAt = ttlSeconds ? new Date(Date.now() + (ttlSeconds * 1000)) : null; await this.pool.query( - `INSERT INTO nuvex_storage (key, value, expires_at) + `INSERT INTO ${this.tableName} (${this.keyColumn}, ${this.valueColumn}, expires_at) VALUES ($1, $2, $3) - ON CONFLICT (key) - DO UPDATE SET value = $2, expires_at = $3, updated_at = NOW()`, + ON CONFLICT (${this.keyColumn}) + DO UPDATE SET ${this.valueColumn} = $2, expires_at = $3, updated_at = NOW()`, [key, JSON.stringify(value), expiresAt] ); } catch (error) { @@ -303,7 +332,7 @@ export class PostgresStorage implements StorageLayerInterface { } try { - await this.pool.query('DELETE FROM nuvex_storage WHERE key = $1', [key]); + await this.pool.query(`DELETE FROM ${this.tableName} WHERE ${this.keyColumn} = $1`, [key]); } catch (error) { this.log('error', `PostgreSQL L3: Error deleting key: ${key}`, { error: error instanceof Error ? error.message : String(error) @@ -333,7 +362,7 @@ export class PostgresStorage implements StorageLayerInterface { try { const result = await this.pool.query( - 'SELECT 1 FROM nuvex_storage WHERE key = $1 AND (expires_at IS NULL OR expires_at > NOW())', + `SELECT 1 FROM ${this.tableName} WHERE ${this.keyColumn} = $1 AND (expires_at IS NULL OR expires_at > NOW())`, [key] ); @@ -363,7 +392,7 @@ export class PostgresStorage implements StorageLayerInterface { } try { - await this.pool.query('DELETE FROM nuvex_storage'); + await this.pool.query(`DELETE FROM ${this.tableName}`); this.log('info', 'PostgreSQL L3: All data cleared'); } catch (error) { this.log('error', 'PostgreSQL L3: Error clearing data', { diff --git a/src/types/index.ts b/src/types/index.ts index 5f72cb9..01029ea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,6 +15,26 @@ import type { Logger } from '../interfaces/index.js'; // ===== Configuration Types ===== +/** + * PostgreSQL schema configuration + * + * Configurable table and column names for PostgreSQL storage layer. + * Enables backwards compatibility with existing applications and custom naming conventions. + * + * @interface PostgresSchemaConfig + */ +export interface PostgresSchemaConfig { + /** Table name for storage (default: 'nuvex_storage') */ + tableName?: string; + /** Column names configuration */ + columns?: { + /** Key column name (default: 'nuvex_key') */ + key?: string; + /** Data/value column name (default: 'nuvex_data') */ + value?: string; + }; +} + /** * PostgreSQL database configuration * @@ -42,6 +62,8 @@ export interface PostgresConfig { idleTimeoutMillis?: number; /** Time to wait for connection establishment (ms) */ connectionTimeoutMillis?: number; + /** Schema configuration for table and column names (optional) */ + schema?: PostgresSchemaConfig; } /**