replies timeline only, appview-less bluesky client
at main 5.6 kB view raw
1import { createCache } from 'async-cache-dedupe'; 2 3const DB_NAME = 'nucleus-cache'; 4const STORE_NAME = 'keyvalue'; 5const DB_VERSION = 1; 6 7type WriteOp = 8 | { 9 type: 'put'; 10 key: string; 11 value: { value: unknown; expires: number }; 12 resolve: () => void; 13 reject: (err: unknown) => void; 14 } 15 | { type: 'delete'; key: string; resolve: () => void; reject: (err: unknown) => void }; 16type ReadOp = { 17 key: string; 18 resolve: (val: unknown) => void; 19 reject: (err: unknown) => void; 20}; 21 22class IDBStorage { 23 private dbPromise: Promise<IDBDatabase> | null = null; 24 25 private getBatch: ReadOp[] = []; 26 private writeBatch: WriteOp[] = []; 27 28 private getFlushScheduled = false; 29 private writeFlushScheduled = false; 30 31 constructor() { 32 if (typeof indexedDB === 'undefined') return; 33 34 this.dbPromise = new Promise((resolve, reject) => { 35 const request = indexedDB.open(DB_NAME, DB_VERSION); 36 37 request.onerror = () => { 38 console.error('IDB open error:', request.error); 39 reject(request.error); 40 }; 41 42 request.onsuccess = () => resolve(request.result); 43 44 request.onupgradeneeded = (event) => { 45 const db = (event.target as IDBOpenDBRequest).result; 46 if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME); 47 }; 48 }); 49 } 50 51 async get(key: string): Promise<unknown> { 52 // checking in-flight writes 53 for (let i = this.writeBatch.length - 1; i >= 0; i--) { 54 const op = this.writeBatch[i]; 55 if (op.key === key) { 56 if (op.type === 'delete') return undefined; 57 if (op.type === 'put') { 58 // if expired we dont want it 59 if (op.value.expires < Date.now()) return undefined; 60 return op.value.value; 61 } 62 } 63 } 64 65 if (!this.dbPromise) return undefined; 66 67 return new Promise((resolve, reject) => { 68 this.getBatch.push({ key, resolve, reject }); 69 this.scheduleGetFlush(); 70 }); 71 } 72 73 private scheduleGetFlush() { 74 if (this.getFlushScheduled) return; 75 this.getFlushScheduled = true; 76 queueMicrotask(() => this.flushGetBatch()); 77 } 78 79 private async flushGetBatch() { 80 this.getFlushScheduled = false; 81 const batch = this.getBatch; 82 this.getBatch = []; 83 84 if (batch.length === 0) return; 85 86 try { 87 const db = await this.dbPromise; 88 if (!db) throw new Error('DB not available'); 89 90 const transaction = db.transaction(STORE_NAME, 'readonly'); 91 const store = transaction.objectStore(STORE_NAME); 92 93 batch.forEach(({ key, resolve }) => { 94 try { 95 const request = store.get(key); 96 request.onsuccess = () => { 97 const result = request.result; 98 if (!result) { 99 resolve(undefined); 100 return; 101 } 102 if (result.expires < Date.now()) { 103 // Fire-and-forget removal for expired items 104 this.remove(key).catch(() => {}); 105 resolve(undefined); 106 return; 107 } 108 resolve(result.value); 109 }; 110 request.onerror = () => resolve(undefined); 111 } catch { 112 resolve(undefined); 113 } 114 }); 115 } catch (error) { 116 batch.forEach(({ reject }) => reject(error)); 117 } 118 } 119 120 async set(key: string, value: unknown, ttl: number): Promise<void> { 121 if (!this.dbPromise) return; 122 123 const expires = Date.now() + ttl * 1000; 124 const storageValue = { value, expires }; 125 126 return new Promise((resolve, reject) => { 127 this.writeBatch.push({ type: 'put', key, value: storageValue, resolve, reject }); 128 this.scheduleWriteFlush(); 129 }); 130 } 131 132 async remove(key: string): Promise<void> { 133 if (!this.dbPromise) return; 134 135 return new Promise((resolve, reject) => { 136 this.writeBatch.push({ type: 'delete', key, resolve, reject }); 137 this.scheduleWriteFlush(); 138 }); 139 } 140 141 private scheduleWriteFlush() { 142 if (this.writeFlushScheduled) return; 143 this.writeFlushScheduled = true; 144 queueMicrotask(() => this.flushWriteBatch()); 145 } 146 147 private async flushWriteBatch() { 148 this.writeFlushScheduled = false; 149 const batch = this.writeBatch; 150 this.writeBatch = []; 151 152 if (batch.length === 0) return; 153 154 try { 155 const db = await this.dbPromise; 156 if (!db) throw new Error('DB not available'); 157 158 const transaction = db.transaction(STORE_NAME, 'readwrite'); 159 const store = transaction.objectStore(STORE_NAME); 160 161 batch.forEach((op) => { 162 try { 163 let request: IDBRequest; 164 if (op.type === 'put') request = store.put(op.value, op.key); 165 else request = store.delete(op.key); 166 167 request.onsuccess = () => op.resolve(); 168 request.onerror = () => op.reject(request.error); 169 } catch (err) { 170 op.reject(err); 171 } 172 }); 173 } catch (error) { 174 batch.forEach(({ reject }) => reject(error)); 175 } 176 } 177 178 async clear(): Promise<void> { 179 if (!this.dbPromise) return; 180 try { 181 const db = await this.dbPromise; 182 return new Promise<void>((resolve, reject) => { 183 const transaction = db.transaction(STORE_NAME, 'readwrite'); 184 const store = transaction.objectStore(STORE_NAME); 185 const request = store.clear(); 186 187 request.onerror = () => reject(request.error); 188 request.onsuccess = () => resolve(); 189 }); 190 } catch (e) { 191 console.error('IDB clear error', e); 192 } 193 } 194 195 async exists(key: string): Promise<boolean> { 196 return (await this.get(key)) !== undefined; 197 } 198 199 async invalidate(key: string): Promise<void> { 200 return this.remove(key); 201 } 202 203 // noops 204 // eslint-disable-next-line @typescript-eslint/no-unused-vars 205 async getTTL(key: string): Promise<void> { 206 return; 207 } 208 async refresh(): Promise<void> { 209 return; 210 } 211} 212 213export const ttl = 60 * 60 * 3; // 3 hours 214 215export const cache = createCache({ 216 storage: { 217 type: 'custom', 218 options: { 219 storage: new IDBStorage() 220 } 221 }, 222 ttl, 223 onError: (err) => console.error(err) 224});