longurl-js
Version:
LongURL - Programmable URL management framework with entity-driven design and production-ready infrastructure
569 lines (568 loc) • 24.6 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");
const qr_storage_js_1 = require("./qr-storage.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) {
var _a, _b, _c, _d, _e;
try {
await this.detectTableSchema();
const schema = this.tableSchema;
// Check if entity already exists (primary lookup by entity_type + entity_id)
const { data: existingEntity, error: lookupError } = await this.client
.from(schema.table)
.select('*')
.eq('entity_type', data.entityType)
.eq('entity_id', data.entityId)
.maybeSingle();
if (lookupError && lookupError.code !== 'PGRST116') {
this.handleError(lookupError, 'save');
}
// Handle QR code storage based on config
// Default: upload to bucket and store URL (storeQRInTable: false)
// Optional: store base64 in table (storeQRInTable: true)
const storeQRInTable = (_c = (_b = (_a = this.config.options) === null || _a === void 0 ? void 0 : _a.storage) === null || _b === void 0 ? void 0 : _b.storeQRInTable) !== null && _c !== void 0 ? _c : false;
const qrCodeBucket = ((_e = (_d = this.config.options) === null || _d === void 0 ? void 0 : _d.storage) === null || _e === void 0 ? void 0 : _e.qrCodeBucket) || 'qr-codes';
let qrCodeUrl = null;
let qrCodeBase64 = null;
if (data.qrCode) {
if (storeQRInTable) {
// Store base64 in qr_code column (old behavior, opt-in)
qrCodeBase64 = data.qrCode;
qrCodeUrl = null;
}
else {
// Default: upload to bucket and store URL
try {
qrCodeUrl = await (0, qr_storage_js_1.uploadQRCodeToBucket)(this.client, data.qrCode, data.entityType, data.entityId, qrCodeBucket);
qrCodeBase64 = null; // Don't store base64 when using bucket
}
catch (error) {
// If upload fails, throw error (no fallback to base64)
throw new Error(`Failed to upload QR code to storage bucket: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
const updateData = {
[schema.slugColumn]: urlId,
url_slug_short: data.urlSlugShort || null,
entity_type: data.entityType,
entity_id: data.entityId,
[schema.baseColumn]: data.originalUrl,
qr_code: qrCodeBase64, // Only set if storeQRInTable: true
qr_code_url: qrCodeUrl, // Only set if storeQRInTable: false (default)
updated_at: data.updatedAt
};
// Merge metadata if entity exists (preserve existing + add new)
if (existingEntity && existingEntity.metadata) {
updateData.metadata = {
...existingEntity.metadata,
...data.metadata
};
}
else {
updateData.metadata = data.metadata || {};
}
if (existingEntity) {
// Entity exists - UPDATE
// If url_slug changed, we need to update it (but check for collisions first)
if (existingEntity[schema.slugColumn] !== urlId) {
// Check if new url_slug already exists (collision check)
const { data: slugExists } = await this.client
.from(schema.table)
.select(schema.slugColumn)
.eq(schema.slugColumn, urlId)
.maybeSingle();
if (slugExists) {
throw new Error(`Cannot update: url_slug "${urlId}" already exists. ` +
`Entity "${data.entityType}/${data.entityId}" currently uses "${existingEntity[schema.slugColumn]}".`);
}
}
// Preserve created_at, update other fields
const { error: updateError } = await this.client
.from(schema.table)
.update(updateData)
.eq('entity_type', data.entityType)
.eq('entity_id', data.entityId);
if (updateError) {
this.handleError(updateError, 'save');
}
// Clear cache for both old and new slugs (including url_slug_short if it exists)
this.cache.delete(existingEntity[schema.slugColumn]);
if (existingEntity.url_slug_short) {
this.cache.delete(existingEntity.url_slug_short);
}
// Update cache with qrCodeUrl if bucket storage was used
const cachedData = { ...data, qrCodeUrl: qrCodeUrl || undefined };
this.setCache(urlId, cachedData);
if (data.urlSlugShort) {
this.setCache(data.urlSlugShort, cachedData);
}
}
else {
// Entity doesn't exist - INSERT
// Check if url_slug already exists (collision check)
const { data: slugExists } = await this.client
.from(schema.table)
.select(schema.slugColumn)
.eq(schema.slugColumn, urlId)
.maybeSingle();
if (slugExists) {
throw new Error(`Cannot create: url_slug "${urlId}" already exists for a different entity. ` +
`Use update() or provide a different entity_id.`);
}
const insertData = {
...updateData,
created_at: data.createdAt
};
const { error: insertError } = await this.client
.from(schema.table)
.insert(insertData);
if (insertError) {
this.handleError(insertError, 'save');
}
// Update cache with qrCodeUrl if bucket storage was used
const cachedData = { ...data, qrCodeUrl: qrCodeUrl || undefined };
this.setCache(urlId, cachedData);
}
}
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;
// Try resolving by url_slug first
let { data, error } = await this.client
.from(schema.table)
.select('*')
.eq(schema.slugColumn, urlId)
.single();
// If not found, try resolving by url_slug_short
if ((error === null || error === void 0 ? void 0 : error.code) === 'PGRST116') {
const { data: shortData, error: shortError } = await this.client
.from(schema.table)
.select('*')
.eq('url_slug_short', urlId)
.single();
if ((shortError === null || shortError === void 0 ? void 0 : shortError.code) === 'PGRST116')
return null; // Not found - expected
if (shortError)
this.handleError(shortError, 'resolve');
if (!shortData)
return null;
data = shortData;
error = null;
}
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,
// Short slug (optional)
urlSlugShort: data.url_slug_short || undefined,
// Common fields
entityType: data.entity_type,
entityId: data.entity_id,
metadata: data.metadata || {},
createdAt: data.created_at,
updatedAt: data.updated_at,
// QR code (from table if storeQRInTable: true, or from bucket URL if false/default)
qrCode: data.qr_code || undefined,
qrCodeUrl: data.qr_code_url || 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;
// Check by url_slug first
let { data, error } = await this.client
.from(schema.table)
.select(schema.slugColumn)
.eq(schema.slugColumn, urlId)
.single();
// If not found, check by url_slug_short
if ((error === null || error === void 0 ? void 0 : error.code) === 'PGRST116') {
const { data: shortData, error: shortError } = await this.client
.from(schema.table)
.select('url_slug_short')
.eq('url_slug_short', urlId)
.single();
if ((shortError === null || shortError === void 0 ? void 0 : shortError.code) === 'PGRST116')
return false; // Not found - expected
if (shortError)
this.handleError(shortError, 'exists');
return !!shortData;
}
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;