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.

206 lines (204 loc) 6.55 kB
// lib/adapters/storage/SequelizeStorage.ts var SequelizeStorage = class { /** * 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) { 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) { 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; } normalizeEmailAddress(email) { 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, data) { 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: /* @__PURE__ */ new Date() }); } catch (error) { 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) { 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) { 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() { const records = await this.model.findAll({ raw: true }); return records.map((record) => ({ email: record.email, metadata: this.safeParseMetadata(record.metadata), createdAt: record.createdAt })); } /** * Count total entries in the database. * @returns Number of entries */ async count() { return this.model.count(); } /** * Clear all entries from the database. */ async clear() { 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, options = {}) { const { limit, offset = 0, caseInsensitive = true } = options; const { Op } = this.model.sequelize.Sequelize; const escapedPattern = pattern.replace(/[%_\\]/g, "\\$&"); const likePattern = `%${escapedPattern}%`; const dialect = this.model.sequelize.getDialect(); let whereClause; if (caseInsensitive) { if (dialect === "postgres") { whereClause = { email: { [Op.iLike]: likePattern } }; } else { 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 = { where: whereClause, raw: true }; if (offset > 0) { queryOptions.offset = offset; } if (limit !== void 0 && limit > 0) { queryOptions.limit = limit; } const records = await this.model.findAll(queryOptions); return records.map((record) => ({ 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 */ safeParseMetadata(metadata) { if (!metadata) return void 0; try { const parsed = JSON.parse(metadata); if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { return parsed; } console.warn("Metadata is not a plain object, ignoring"); return void 0; } catch (error) { console.error("Failed to parse metadata JSON:", error); return void 0; } } /** * 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 = 100) { let offset = 0; let hasMore = true; while (hasMore) { const records = await this.model.findAll({ raw: true, limit: batchSize, 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; } } } }; export { SequelizeStorage };