A community based topic aggregation platform built on atproto
1package unfurl 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "fmt" 8 "time" 9) 10 11type postgresUnfurlRepo struct { 12 db *sql.DB 13} 14 15// NewRepository creates a new PostgreSQL unfurl cache repository 16func NewRepository(db *sql.DB) Repository { 17 return &postgresUnfurlRepo{db: db} 18} 19 20// Get retrieves a cached unfurl result for the given URL. 21// Returns nil, nil if not found or expired (not an error condition). 22// Returns error only on database failures. 23func (r *postgresUnfurlRepo) Get(ctx context.Context, url string) (*UnfurlResult, error) { 24 query := ` 25 SELECT metadata, thumbnail_url, provider 26 FROM unfurl_cache 27 WHERE url = $1 AND expires_at > NOW() 28 ` 29 30 var metadataJSON []byte 31 var thumbnailURL sql.NullString 32 var provider string 33 34 err := r.db.QueryRowContext(ctx, query, url).Scan(&metadataJSON, &thumbnailURL, &provider) 35 if err == sql.ErrNoRows { 36 // Not found or expired is not an error 37 return nil, nil 38 } 39 if err != nil { 40 return nil, fmt.Errorf("failed to get unfurl cache entry: %w", err) 41 } 42 43 // Unmarshal metadata JSONB to UnfurlResult 44 var result UnfurlResult 45 if err := json.Unmarshal(metadataJSON, &result); err != nil { 46 return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) 47 } 48 49 // Ensure provider and thumbnailURL are set (may not be in metadata JSON) 50 result.Provider = provider 51 if thumbnailURL.Valid { 52 result.ThumbnailURL = thumbnailURL.String 53 } 54 55 return &result, nil 56} 57 58// Set stores an unfurl result in the cache with the specified TTL. 59// If an entry already exists for the URL, it will be updated. 60// The expires_at is calculated as NOW() + ttl. 61func (r *postgresUnfurlRepo) Set(ctx context.Context, url string, result *UnfurlResult, ttl time.Duration) error { 62 // Marshal UnfurlResult to JSON for metadata column 63 metadataJSON, err := json.Marshal(result) 64 if err != nil { 65 return fmt.Errorf("failed to marshal metadata: %w", err) 66 } 67 68 // Store thumbnail_url separately for potential queries 69 var thumbnailURL sql.NullString 70 if result.ThumbnailURL != "" { 71 thumbnailURL.String = result.ThumbnailURL 72 thumbnailURL.Valid = true 73 } 74 75 // Convert Go duration to PostgreSQL interval string 76 // e.g., "1 hour", "24 hours", "7 days" 77 intervalStr := formatInterval(ttl) 78 79 query := ` 80 INSERT INTO unfurl_cache (url, provider, metadata, thumbnail_url, expires_at) 81 VALUES ($1, $2, $3, $4, NOW() + $5::interval) 82 ON CONFLICT (url) DO UPDATE 83 SET provider = EXCLUDED.provider, 84 metadata = EXCLUDED.metadata, 85 thumbnail_url = EXCLUDED.thumbnail_url, 86 expires_at = EXCLUDED.expires_at, 87 fetched_at = NOW() 88 ` 89 90 _, err = r.db.ExecContext(ctx, query, url, result.Provider, metadataJSON, thumbnailURL, intervalStr) 91 if err != nil { 92 return fmt.Errorf("failed to insert/update unfurl cache entry: %w", err) 93 } 94 95 return nil 96} 97 98// formatInterval converts a Go duration to a PostgreSQL interval string 99// PostgreSQL accepts intervals like "1 hour", "24 hours", "7 days" 100func formatInterval(d time.Duration) string { 101 seconds := int64(d.Seconds()) 102 103 // Convert to appropriate unit for readability 104 switch { 105 case seconds >= 86400: // >= 1 day 106 days := seconds / 86400 107 return fmt.Sprintf("%d days", days) 108 case seconds >= 3600: // >= 1 hour 109 hours := seconds / 3600 110 return fmt.Sprintf("%d hours", hours) 111 case seconds >= 60: // >= 1 minute 112 minutes := seconds / 60 113 return fmt.Sprintf("%d minutes", minutes) 114 default: 115 return fmt.Sprintf("%d seconds", seconds) 116 } 117}