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