UNPKG

waitlist-mailer

Version:

Modern, modular TypeScript library for managing waitlists with pluggable storage and mail providers. Supports MongoDB, SQL databases, and custom adapters with zero required dependencies for basic usage.

258 lines (229 loc) 8.18 kB
/** * Sequelize-based storage adapter for waitlist-mailer. * Supports PostgreSQL, MySQL, and SQLite through Sequelize ORM. * Requires: sequelize, mysql2 (for MySQL), or pg (for PostgreSQL) as peer dependencies */ import { StorageProvider, WaitlistEntry, SearchOptions } from '../../types'; export interface SequelizeStorageConfig { /** Sequelize model instance representing the waitlist table */ model: any; /** Whether to use lowercase for email normalization */ normalizeEmail?: boolean; } /** * Sequelize-based storage provider implementation. * Works with PostgreSQL, MySQL, and SQLite. * Requires sequelize to be installed separately. * @template T - Optional metadata type */ export class SequelizeStorage<T = any> implements StorageProvider<T> { private model: any; private normalizeEmail: boolean; /** * Create a new SequelizeStorage instance. * @param config - Configuration object with sequelize model * @throws {Error} If sequelize is not installed or model is invalid */ constructor(config: SequelizeStorageConfig) { // Verify sequelize is available and model is valid try { if (!config.model) { throw new Error('Sequelize model is required. Please provide a valid Sequelize model instance.'); } if (!config.model.sequelize) { throw new Error('Invalid Sequelize model. Make sure you\'re passing a compiled Sequelize model.'); } if (!config.model.findOne || !config.model.create) { throw new Error('Invalid Sequelize model. Model must have findOne and create methods.'); } } catch (error: any) { if (error.message.includes('Sequelize') || error.message.includes('model')) { throw error; } throw new Error( 'SequelizeStorage requires sequelize to be installed. ' + 'Install it with: npm install sequelize pg (for PostgreSQL) ' + 'or npm install sequelize mysql2 (for MySQL) ' + 'or npm install sequelize better-sqlite3 (for SQLite)' ); } this.model = config.model; this.normalizeEmail = config.normalizeEmail ?? true; } private normalizeEmailAddress(email: string): string { return this.normalizeEmail ? email.toLowerCase() : email; } /** * Add an email to the database. * @param email - The email to add * @param data - Optional metadata * @throws {Error} If the email already exists or database operation fails */ async add(email: string, data?: T): Promise<void> { const normalized = this.normalizeEmailAddress(email); const existing = await this.model.findOne({ where: { email: normalized } }); if (existing) { throw new Error(`Email already exists: ${email}`); } try { await this.model.create({ email: normalized, metadata: data ? JSON.stringify(data) : null, createdAt: new Date(), }); } catch (error: any) { if (error.name === 'SequelizeUniqueConstraintError') { throw new Error(`Email already exists: ${email}`); } throw error; } } /** * Remove an email from the database. * @param email - The email to remove * @returns true if the email was found and removed */ async remove(email: string): Promise<boolean> { const normalized = this.normalizeEmailAddress(email); const result = await this.model.destroy({ where: { email: normalized } }); return result > 0; } /** * Check if an email exists in the database. * @param email - The email to check * @returns true if the email exists */ async exists(email: string): Promise<boolean> { const normalized = this.normalizeEmailAddress(email); const count = await this.model.count({ where: { email: normalized } }); return count > 0; } /** * Retrieve all entries from the database. * @returns Array of all WaitlistEntry objects */ async getAll(): Promise<WaitlistEntry<T>[]> { const records = await this.model.findAll({ raw: true }); return records.map((record: any) => ({ email: record.email, metadata: this.safeParseMetadata(record.metadata), createdAt: record.createdAt, })); } /** * Count total entries in the database. * @returns Number of entries */ async count(): Promise<number> { return this.model.count(); } /** * Clear all entries from the database. */ async clear(): Promise<void> { await this.model.destroy({ where: {} }); } /** * Search entries by email pattern using SQL LIKE/ILIKE. * Delegates filtering to the database for optimal performance. * @param pattern - The pattern to search for * @param options - Optional search options * @returns Matching entries */ async search(pattern: string, options: SearchOptions = {}): Promise<WaitlistEntry<T>[]> { const { limit, offset = 0, caseInsensitive = true } = options; const { Op } = this.model.sequelize.Sequelize; // Escape SQL LIKE wildcards to prevent injection const escapedPattern = pattern.replace(/[%_\\]/g, '\\$&'); const likePattern = `%${escapedPattern}%`; // Use ILIKE for PostgreSQL (case-insensitive), LIKE for others const dialect = this.model.sequelize.getDialect(); let whereClause: any; if (caseInsensitive) { if (dialect === 'postgres') { // PostgreSQL supports ILIKE for case-insensitive matching whereClause = { email: { [Op.iLike]: likePattern } }; } else { // MySQL/SQLite: use LOWER() for case-insensitive matching whereClause = this.model.sequelize.where( this.model.sequelize.fn('LOWER', this.model.sequelize.col('email')), { [Op.like]: likePattern.toLowerCase() } ); } } else { whereClause = { email: { [Op.like]: likePattern } }; } const queryOptions: any = { where: whereClause, raw: true, }; if (offset > 0) { queryOptions.offset = offset; } if (limit !== undefined && limit > 0) { queryOptions.limit = limit; } const records = await this.model.findAll(queryOptions); return records.map((record: any) => ({ email: record.email, metadata: this.safeParseMetadata(record.metadata), createdAt: record.createdAt, })); } /** * Safely parse metadata JSON with validation. * Prevents potential issues from malformed or malicious JSON. * @param metadata - Raw metadata string from database * @returns Parsed metadata or undefined */ private safeParseMetadata(metadata: string | null | undefined): T | undefined { if (!metadata) return undefined; try { const parsed = JSON.parse(metadata); // Ensure parsed result is an object (not array, string, number, etc.) if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { return parsed as T; } // If it's not a plain object, wrap it or return undefined for safety console.warn('Metadata is not a plain object, ignoring'); return undefined; } catch (error) { console.error('Failed to parse metadata JSON:', error); return undefined; } } /** * Iterate over entries using Sequelize pagination for memory-efficient streaming. * Uses limit/offset pagination to avoid loading all records into memory. * @param batchSize - Number of entries per batch (default: 100) */ async *iterate(batchSize: number = 100): AsyncIterableIterator<WaitlistEntry<T>> { let offset = 0; let hasMore = true; while (hasMore) { const records = await this.model.findAll({ raw: true, limit: batchSize, offset: offset, order: [['createdAt', 'ASC']], // Consistent ordering for pagination }); if (records.length === 0) { hasMore = false; } else { for (const record of records) { yield { email: record.email, metadata: this.safeParseMetadata(record.metadata), createdAt: record.createdAt, }; } offset += batchSize; hasMore = records.length === batchSize; } } } }