longurl-js
Version:
LongURL - Programmable URL management framework with entity-driven design and production-ready infrastructure
437 lines (436 loc) • 17.5 kB
JavaScript
"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;