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}