Storage implementations for AT Protocol OAuth applications. Provides a simple key-value storage interface with implementations for in-memory and SQLite backends.
at main 232 lines 5.9 kB view raw
1/** 2 * SQLite storage implementation for OAuth sessions. 3 * Works with any SQLite driver via adapters. 4 */ 5 6import type { Logger, OAuthStorage, SQLiteAdapter } from "./types.ts"; 7 8/** No-op logger for production use */ 9const noopLogger: Logger = { 10 log: () => {}, 11 warn: () => {}, 12 error: () => {}, 13}; 14 15/** 16 * Configuration options for SQLiteStorage 17 */ 18export interface SQLiteStorageOptions { 19 /** Custom table name (default: "oauth_storage") */ 20 tableName?: string; 21 /** Logger for debugging (default: no-op) */ 22 logger?: Logger; 23} 24 25/** 26 * SQLite storage for OAuth sessions and tokens. 27 * 28 * Features: 29 * - Automatic table creation 30 * - TTL-based expiration 31 * - Works with any SQLite driver via adapters 32 * - JSON serialization for complex values 33 * 34 * @example Val.Town / libSQL 35 * ```typescript 36 * import { sqlite } from "https://esm.town/v/std/sqlite"; 37 * import { SQLiteStorage, valTownAdapter } from "@tijs/atproto-storage"; 38 * 39 * const storage = new SQLiteStorage(valTownAdapter(sqlite), { 40 * tableName: "oauth_storage", 41 * logger: console, 42 * }); 43 * 44 * // Store with TTL 45 * await storage.set("session:123", { did: "did:plc:abc" }, { ttl: 3600 }); 46 * 47 * // Retrieve 48 * const session = await storage.get("session:123"); 49 * ``` 50 * 51 * @example Deno native SQLite 52 * ```typescript 53 * import { Database } from "jsr:@db/sqlite"; 54 * import { SQLiteStorage, denoSqliteAdapter } from "@tijs/atproto-storage"; 55 * 56 * const db = new Database("storage.db"); 57 * const storage = new SQLiteStorage(denoSqliteAdapter(db)); 58 * ``` 59 */ 60export class SQLiteStorage implements OAuthStorage { 61 private initialized = false; 62 private readonly tableName: string; 63 private readonly logger: Logger; 64 65 constructor( 66 private adapter: SQLiteAdapter, 67 options?: SQLiteStorageOptions, 68 ) { 69 this.tableName = options?.tableName ?? "oauth_storage"; 70 this.logger = options?.logger ?? noopLogger; 71 } 72 73 private async init(): Promise<void> { 74 if (this.initialized) return; 75 76 // Create table if it doesn't exist 77 await this.adapter.execute( 78 ` 79 CREATE TABLE IF NOT EXISTS ${this.tableName} ( 80 key TEXT PRIMARY KEY, 81 value TEXT NOT NULL, 82 expires_at TEXT, 83 created_at TEXT NOT NULL, 84 updated_at TEXT NOT NULL 85 ) 86 `, 87 [], 88 ); 89 90 // Create index on expires_at for efficient cleanup queries 91 await this.adapter.execute( 92 `CREATE INDEX IF NOT EXISTS idx_${this.tableName}_expires_at ON ${this.tableName}(expires_at)`, 93 [], 94 ); 95 96 this.initialized = true; 97 } 98 99 async get<T = unknown>(key: string): Promise<T | null> { 100 await this.init(); 101 102 const now = Date.now(); 103 this.logger.log("[SQLiteStorage.get]", { key }); 104 105 const rows = await this.adapter.execute( 106 ` 107 SELECT value, expires_at FROM ${this.tableName} 108 WHERE key = ? 109 LIMIT 1 110 `, 111 [key], 112 ); 113 114 if (rows.length === 0) { 115 this.logger.log("[SQLiteStorage.get] Key not found"); 116 return null; 117 } 118 119 // Parse expires_at from TEXT to number 120 const expiresAtRaw = rows[0][1]; 121 const expiresAt = expiresAtRaw !== null 122 ? parseInt(expiresAtRaw as string, 10) 123 : null; 124 125 // Check expiration 126 if (expiresAt !== null && expiresAt <= now) { 127 this.logger.log("[SQLiteStorage.get] Key expired"); 128 return null; 129 } 130 131 try { 132 const value = rows[0][0] as string; 133 const parsed = JSON.parse(value) as T; 134 this.logger.log("[SQLiteStorage.get] Returning parsed value"); 135 return parsed; 136 } catch { 137 this.logger.log("[SQLiteStorage.get] Returning raw value"); 138 return rows[0][0] as T; 139 } 140 } 141 142 async set<T = unknown>( 143 key: string, 144 value: T, 145 options?: { ttl?: number }, 146 ): Promise<void> { 147 await this.init(); 148 149 const now = Date.now(); 150 const expiresAt = options?.ttl ? now + (options.ttl * 1000) : null; 151 const serializedValue = typeof value === "string" 152 ? value 153 : JSON.stringify(value); 154 155 this.logger.log("[SQLiteStorage.set]", { 156 key, 157 ttl: options?.ttl, 158 expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, 159 }); 160 161 await this.adapter.execute( 162 ` 163 INSERT INTO ${this.tableName} (key, value, expires_at, created_at, updated_at) 164 VALUES (?, ?, ?, ?, ?) 165 ON CONFLICT(key) DO UPDATE SET 166 value = excluded.value, 167 expires_at = excluded.expires_at, 168 updated_at = excluded.updated_at 169 `, 170 [ 171 key, 172 serializedValue, 173 expiresAt !== null ? expiresAt.toString() : null, 174 now.toString(), 175 now.toString(), 176 ], 177 ); 178 179 this.logger.log("[SQLiteStorage.set] Stored successfully"); 180 } 181 182 async delete(key: string): Promise<void> { 183 await this.init(); 184 185 this.logger.log("[SQLiteStorage.delete]", { key }); 186 187 await this.adapter.execute( 188 `DELETE FROM ${this.tableName} WHERE key = ?`, 189 [key], 190 ); 191 } 192 193 /** 194 * Clean up expired entries from the database. 195 * Call this periodically to keep the table size manageable. 196 * 197 * @returns Number of entries deleted 198 */ 199 async cleanup(): Promise<number> { 200 await this.init(); 201 202 const now = Date.now(); 203 this.logger.log("[SQLiteStorage.cleanup] Removing expired entries"); 204 205 // Get count before deletion 206 const countRows = await this.adapter.execute( 207 ` 208 SELECT COUNT(*) FROM ${this.tableName} 209 WHERE expires_at IS NOT NULL AND CAST(expires_at AS INTEGER) <= ? 210 `, 211 [now], 212 ); 213 214 const count = countRows[0]?.[0] as number ?? 0; 215 216 if (count > 0) { 217 await this.adapter.execute( 218 ` 219 DELETE FROM ${this.tableName} 220 WHERE expires_at IS NOT NULL AND CAST(expires_at AS INTEGER) <= ? 221 `, 222 [now], 223 ); 224 225 this.logger.log( 226 `[SQLiteStorage.cleanup] Deleted ${count} expired entries`, 227 ); 228 } 229 230 return count; 231 } 232}