Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import postgres from 'postgres';
2import { createHash } from 'crypto';
3
4// Global cache-only mode flag (set by index.ts)
5let cacheOnlyMode = false;
6
7export function setCacheOnlyMode(enabled: boolean) {
8 cacheOnlyMode = enabled;
9 if (enabled) {
10 console.log('[DB] Cache-only mode enabled - database writes will be skipped');
11 }
12}
13
14const sql = postgres(
15 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
16 {
17 max: 10,
18 idle_timeout: 20,
19 }
20);
21
22// Domain lookup cache with TTL
23const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
24
25interface CachedDomain<T> {
26 value: T;
27 timestamp: number;
28}
29
30const domainCache = new Map<string, CachedDomain<DomainLookup | null>>();
31const customDomainCache = new Map<string, CachedDomain<CustomDomainLookup | null>>();
32
33let cleanupInterval: NodeJS.Timeout | null = null;
34
35export function startDomainCacheCleanup() {
36 if (cleanupInterval) return;
37
38 cleanupInterval = setInterval(() => {
39 const now = Date.now();
40
41 for (const [key, entry] of domainCache.entries()) {
42 if (now - entry.timestamp > DOMAIN_CACHE_TTL) {
43 domainCache.delete(key);
44 }
45 }
46
47 for (const [key, entry] of customDomainCache.entries()) {
48 if (now - entry.timestamp > DOMAIN_CACHE_TTL) {
49 customDomainCache.delete(key);
50 }
51 }
52 }, 30 * 60 * 1000); // Run every 30 minutes
53}
54
55export function stopDomainCacheCleanup() {
56 if (cleanupInterval) {
57 clearInterval(cleanupInterval);
58 cleanupInterval = null;
59 }
60}
61
62export interface DomainLookup {
63 did: string;
64 rkey: string | null;
65}
66
67export interface CustomDomainLookup {
68 id: string;
69 domain: string;
70 did: string;
71 rkey: string | null;
72 verified: boolean;
73}
74
75
76
77export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
78 const key = domain.toLowerCase();
79
80 // Check cache first
81 const cached = domainCache.get(key);
82 if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
83 return cached.value;
84 }
85
86 // Query database
87 const result = await sql<DomainLookup[]>`
88 SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1
89 `;
90 const data = result[0] || null;
91
92 // Cache the result
93 domainCache.set(key, { value: data, timestamp: Date.now() });
94
95 return data;
96}
97
98export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
99 const key = domain.toLowerCase();
100
101 // Check cache first
102 const cached = customDomainCache.get(key);
103 if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
104 return cached.value;
105 }
106
107 // Query database
108 const result = await sql<CustomDomainLookup[]>`
109 SELECT id, domain, did, rkey, verified FROM custom_domains
110 WHERE domain = ${key} AND verified = true LIMIT 1
111 `;
112 const data = result[0] || null;
113
114 // Cache the result
115 customDomainCache.set(key, { value: data, timestamp: Date.now() });
116
117 return data;
118}
119
120export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
121 const key = `hash:${hash}`;
122
123 // Check cache first
124 const cached = customDomainCache.get(key);
125 if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
126 return cached.value;
127 }
128
129 // Query database
130 const result = await sql<CustomDomainLookup[]>`
131 SELECT id, domain, did, rkey, verified FROM custom_domains
132 WHERE id = ${hash} AND verified = true LIMIT 1
133 `;
134 const data = result[0] || null;
135
136 // Cache the result
137 customDomainCache.set(key, { value: data, timestamp: Date.now() });
138
139 return data;
140}
141
142export async function upsertSite(did: string, rkey: string, displayName?: string) {
143 // Skip database writes in cache-only mode
144 if (cacheOnlyMode) {
145 console.log('[DB] Skipping upsertSite (cache-only mode)', { did, rkey });
146 return;
147 }
148
149 try {
150 // Only set display_name if provided (not undefined/null/empty)
151 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
152
153 await sql`
154 INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
155 VALUES (${did}, ${rkey}, ${cleanDisplayName}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
156 ON CONFLICT (did, rkey)
157 DO UPDATE SET
158 display_name = CASE
159 WHEN EXCLUDED.display_name IS NOT NULL THEN EXCLUDED.display_name
160 ELSE sites.display_name
161 END,
162 updated_at = EXTRACT(EPOCH FROM NOW())
163 `;
164 } catch (err) {
165 console.error('Failed to upsert site', err);
166 }
167}
168
169export interface SiteRecord {
170 did: string;
171 rkey: string;
172 display_name?: string;
173}
174
175export async function getAllSites(): Promise<SiteRecord[]> {
176 try {
177 const result = await sql<SiteRecord[]>`
178 SELECT did, rkey, display_name FROM sites
179 ORDER BY created_at DESC
180 `;
181 return result;
182 } catch (err) {
183 console.error('Failed to get all sites', err);
184 return [];
185 }
186}
187
188/**
189 * Generate a numeric lock ID from a string key
190 * PostgreSQL advisory locks use bigint (64-bit signed integer)
191 */
192function stringToLockId(key: string): bigint {
193 const hash = createHash('sha256').update(key).digest('hex');
194 // Take first 16 hex characters (64 bits) and convert to bigint
195 const hashNum = BigInt('0x' + hash.substring(0, 16));
196 // Keep within signed int64 range
197 return hashNum & 0x7FFFFFFFFFFFFFFFn;
198}
199
200/**
201 * Acquire a distributed lock using PostgreSQL advisory locks
202 * Returns true if lock was acquired, false if already held by another instance
203 * Lock is automatically released when the transaction ends or connection closes
204 */
205export async function tryAcquireLock(key: string): Promise<boolean> {
206 const lockId = stringToLockId(key);
207
208 try {
209 const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`;
210 return result[0]?.acquired === true;
211 } catch (err) {
212 console.error('Failed to acquire lock', { key, error: err });
213 return false;
214 }
215}
216
217/**
218 * Release a distributed lock
219 */
220export async function releaseLock(key: string): Promise<void> {
221 const lockId = stringToLockId(key);
222
223 try {
224 await sql`SELECT pg_advisory_unlock(${Number(lockId)})`;
225 } catch (err) {
226 console.error('Failed to release lock', { key, error: err });
227 }
228}
229
230export { sql };