UNPKG

longurl-js

Version:

LongURL - Programmable URL management framework with entity-driven design and production-ready infrastructure

437 lines (436 loc) 17.5 kB
"use strict"; /** * Supabase Storage Adapter * * Production-ready adapter for Supabase/PostgreSQL storage. * Includes intelligent caching and detailed error handling. * Retry logic is handled by the user's application layer. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SupabaseAdapter = void 0; const supabase_js_1 = require("@supabase/supabase-js"); const StorageAdapter_js_1 = require("../../core/storage/StorageAdapter.js"); const errors_js_1 = require("./errors.js"); class SupabaseAdapter extends StorageAdapter_js_1.StorageAdapter { constructor(config) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o; super(); this.cache = new Map(); this.tableSchema = null; this.config = config; // Allow environment variable override for table names // Default to new naming, but will auto-detect and fall back to legacy this.tableName = process.env.LONGURL_TABLE_NAME || 'endpoints'; this.analyticsTable = process.env.LONGURL_ANALYTICS_TABLE_NAME || 'url_analytics'; // Cache configuration this.cacheEnabled = (_c = (_b = (_a = config.options) === null || _a === void 0 ? void 0 : _a.cache) === null || _b === void 0 ? void 0 : _b.enabled) !== null && _c !== void 0 ? _c : true; this.cacheTimeout = (_f = (_e = (_d = config.options) === null || _d === void 0 ? void 0 : _d.cache) === null || _e === void 0 ? void 0 : _e.ttlMs) !== null && _f !== void 0 ? _f : 5 * 60 * 1000; // 5 minutes this.maxCacheSize = (_j = (_h = (_g = config.options) === null || _g === void 0 ? void 0 : _g.cache) === null || _h === void 0 ? void 0 : _h.maxSize) !== null && _j !== void 0 ? _j : 1000; // Create Supabase client (connection pooling handled internally) this.client = (0, supabase_js_1.createClient)(config.url, config.key, { db: { schema: (_l = (_k = config.options) === null || _k === void 0 ? void 0 : _k.schema) !== null && _l !== void 0 ? _l : 'public', }, global: { headers: (_o = (_m = config.options) === null || _m === void 0 ? void 0 : _m.headers) !== null && _o !== void 0 ? _o : {}, }, }); } /** * Handle errors with detailed context - no retry logic */ handleError(error, operation) { const enhancedError = (0, errors_js_1.parseSupabaseError)(error, operation, this.tableName); (0, errors_js_1.logSupabaseError)(enhancedError, { operation, tableName: this.tableName }); throw enhancedError; } /** * Intelligent cache management with size limits and expiration */ cleanupCache() { if (!this.cacheEnabled) return; const now = Date.now(); // Remove expired entries for (const [key, entry] of this.cache.entries()) { if (entry.expires < now) { this.cache.delete(key); } } // If still over limit, remove oldest entries (LRU-style) if (this.cache.size > this.maxCacheSize) { const entries = Array.from(this.cache.entries()); const toRemove = entries .sort((a, b) => a[1].expires - b[1].expires) .slice(0, this.cache.size - this.maxCacheSize); toRemove.forEach(([key]) => this.cache.delete(key)); } } getCached(urlId) { if (!this.cacheEnabled) return null; const entry = this.cache.get(urlId); if (!entry || entry.expires < Date.now()) { this.cache.delete(urlId); return null; } return entry.data; } setCache(urlId, data) { if (!this.cacheEnabled) return; this.cache.set(urlId, { data, expires: Date.now() + this.cacheTimeout }); // Cleanup if needed if (this.cache.size > this.maxCacheSize) { this.cleanupCache(); } } /** * Auto-detect table schema for backwards compatibility */ async detectTableSchema() { if (this.tableSchema) return; // Already detected // Try new schema first: endpoints table with url_slug/url_base try { const { error: newError } = await this.client.from('endpoints').select('url_slug, url_base').limit(1); if (!newError) { this.tableSchema = { table: 'endpoints', slugColumn: 'url_slug', baseColumn: 'url_base', analyticsSlugColumn: 'url_slug' }; this.tableName = 'endpoints'; return; } } catch (error) { // Continue to legacy detection } // Try legacy schema: short_urls table with url_id/original_url try { const { error: legacyError } = await this.client.from('short_urls').select('url_id, original_url').limit(1); if (!legacyError) { this.tableSchema = { table: 'short_urls', slugColumn: 'url_id', baseColumn: 'original_url', analyticsSlugColumn: 'url_id' }; this.tableName = 'short_urls'; return; } } catch (error) { // Continue to custom table detection } // Try custom table name with new schema if (this.tableName !== 'endpoints' && this.tableName !== 'short_urls') { try { const { error: customError } = await this.client.from(this.tableName).select('url_slug, url_base').limit(1); if (!customError) { this.tableSchema = { table: this.tableName, slugColumn: 'url_slug', baseColumn: 'url_base', analyticsSlugColumn: 'url_slug' }; return; } } catch (error) { // Try legacy column names on custom table try { const { error: customLegacyError } = await this.client.from(this.tableName).select('url_id, original_url').limit(1); if (!customLegacyError) { this.tableSchema = { table: this.tableName, slugColumn: 'url_id', baseColumn: 'original_url', analyticsSlugColumn: 'url_id' }; return; } } catch (error) { // Fall through to error } } } // Default to new schema if nothing detected this.tableSchema = { table: this.tableName, slugColumn: 'url_slug', baseColumn: 'url_base', analyticsSlugColumn: 'url_slug' }; } async initialize() { try { await this.detectTableSchema(); const { error } = await this.client.from(this.tableSchema.table).select('count').limit(1); if (error && error.code === 'PGRST116') { console.warn(`Table ${this.tableSchema.table} not found. Please create it manually.`); console.warn(`Run setup-tables.sql or migration-from-short-urls.sql to create the proper schema.`); } else if (error) { this.handleError(error, 'initialize'); } } catch (error) { this.handleError(error, 'initialize'); } } async save(urlId, data) { try { await this.detectTableSchema(); const schema = this.tableSchema; const insertData = { [schema.slugColumn]: urlId, entity_type: data.entityType, entity_id: data.entityId, [schema.baseColumn]: data.originalUrl, qr_code: data.qrCode || null, metadata: data.metadata || {}, created_at: data.createdAt, updated_at: data.updatedAt }; const { error } = await this.client .from(schema.table) .insert(insertData); if (error) { this.handleError(error, 'save'); } this.setCache(urlId, data); } catch (error) { this.handleError(error, 'save'); } } async resolve(urlId) { // Check cache first const cached = this.getCached(urlId); if (cached) return cached; try { await this.detectTableSchema(); const schema = this.tableSchema; const { data, error } = await this.client .from(schema.table) .select('*') .eq(schema.slugColumn, urlId) .single(); if ((error === null || error === void 0 ? void 0 : error.code) === 'PGRST116') return null; // Not found - expected if (error) this.handleError(error, 'resolve'); if (!data) return null; const slugValue = data[schema.slugColumn]; const baseValue = data[schema.baseColumn]; const entityData = { // Legacy naming (backward compatibility) urlId: slugValue, originalUrl: baseValue, // New naming (preferred) urlSlug: slugValue, urlBase: baseValue, // Common fields entityType: data.entity_type, entityId: data.entity_id, metadata: data.metadata || {}, createdAt: data.created_at, updatedAt: data.updated_at, // QR code qrCode: data.qr_code || undefined }; this.setCache(urlId, entityData); return entityData; } catch (error) { this.handleError(error, 'resolve'); } } async exists(urlId) { if (this.getCached(urlId)) return true; try { await this.detectTableSchema(); const schema = this.tableSchema; const { data, error } = await this.client .from(schema.table) .select(schema.slugColumn) .eq(schema.slugColumn, urlId) .single(); if ((error === null || error === void 0 ? void 0 : error.code) === 'PGRST116') return false; // Not found - expected if (error) this.handleError(error, 'exists'); return !!data; } catch (error) { this.handleError(error, 'exists'); } } async incrementClicks(urlId, metadata) { try { await this.detectTableSchema(); const schema = this.tableSchema; // Try atomic increment first (if RPC exists) const { error: rpcError } = await this.client.rpc('increment_click_count', { url_slug_param: urlId }); if (rpcError && rpcError.code !== '42883') { // 42883 = function doesn't exist this.handleError(rpcError, 'incrementClicks'); } // Fallback to manual increment if RPC doesn't exist if ((rpcError === null || rpcError === void 0 ? void 0 : rpcError.code) === '42883') { const { data: currentData, error: selectError } = await this.client .from(schema.table) .select('click_count') .eq(schema.slugColumn, urlId) .single(); if (selectError) this.handleError(selectError, 'incrementClicks'); const { error: updateError } = await this.client .from(schema.table) .update({ click_count: ((currentData === null || currentData === void 0 ? void 0 : currentData.click_count) || 0) + 1, updated_at: new Date().toISOString() }) .eq(schema.slugColumn, urlId); if (updateError) this.handleError(updateError, 'incrementClicks'); } // Record analytics (non-blocking - don't throw on analytics errors) this.client .from(this.analyticsTable) .insert({ [schema.analyticsSlugColumn]: urlId, timestamp: new Date().toISOString(), metadata: metadata || {} }) .then(({ error }) => { if (error) console.warn(`Analytics recording failed: ${error.message}`); }); // Invalidate cache this.cache.delete(urlId); } catch (error) { this.handleError(error, 'incrementClicks'); } } async getAnalytics(urlId) { try { await this.detectTableSchema(); const schema = this.tableSchema; const { data: urlData, error: urlError } = await this.client .from(schema.table) .select('click_count, created_at, updated_at') .eq(schema.slugColumn, urlId) .single(); if ((urlError === null || urlError === void 0 ? void 0 : urlError.code) === 'PGRST116') return null; // Not found - expected if (urlError) this.handleError(urlError, 'getAnalytics'); const { data: clickData } = await this.client .from(this.analyticsTable) .select('timestamp, metadata') .eq(schema.analyticsSlugColumn, urlId) .order('timestamp', { ascending: false }) .limit(100); const clickHistory = clickData || []; const lastClick = clickHistory[0]; return { // Legacy naming (backward compatibility) urlId, // New naming (preferred) urlSlug: urlId, // Analytics data totalClicks: urlData.click_count || 0, createdAt: urlData.created_at, updatedAt: urlData.updated_at, lastClickAt: lastClick === null || lastClick === void 0 ? void 0 : lastClick.timestamp, clickHistory: clickHistory.map(click => { var _a, _b, _c, _d; return ({ timestamp: click.timestamp, userAgent: (_a = click.metadata) === null || _a === void 0 ? void 0 : _a.userAgent, referer: (_b = click.metadata) === null || _b === void 0 ? void 0 : _b.referer, ip: (_c = click.metadata) === null || _c === void 0 ? void 0 : _c.ip, country: (_d = click.metadata) === null || _d === void 0 ? void 0 : _d.country }); }) }; } catch (error) { this.handleError(error, 'getAnalytics'); } } async close() { this.cache.clear(); } async healthCheck() { try { await this.detectTableSchema(); const schema = this.tableSchema; const { error } = await this.client .from(schema.table) .select('count') .limit(1); return !error; } catch { return false; // Don't throw on health check - just return false } } // Supabase-specific methods async subscribeToChanges(callback) { await this.detectTableSchema(); const schema = this.tableSchema; return this.client .channel('url-changes') .on('postgres_changes', { event: '*', schema: 'public', table: schema.table }, callback) .subscribe(); } async saveBatch(data) { try { await this.detectTableSchema(); const schema = this.tableSchema; const insertData = data.map(item => ({ [schema.slugColumn]: item.urlId, entity_type: item.entityType, entity_id: item.entityId, [schema.baseColumn]: item.originalUrl, metadata: item.metadata || {}, created_at: item.createdAt, updated_at: item.updatedAt })); const { error } = await this.client .from(schema.table) .insert(insertData); if (error) this.handleError(error, 'saveBatch'); data.forEach(item => this.setCache(item.urlId, item)); } catch (error) { this.handleError(error, 'saveBatch'); } } getCacheStats() { return { size: this.cache.size, maxSize: this.maxCacheSize, enabled: this.cacheEnabled }; } } exports.SupabaseAdapter = SupabaseAdapter;