Storage implementations for AT Protocol OAuth applications. Provides a simple key-value storage interface with implementations for in-memory and SQLite backends.

Initial release v0.1.0

Framework-agnostic storage implementations for AT Protocol OAuth:
- OAuthStorage interface with TTL support
- MemoryStorage for testing/development
- SQLiteStorage with adapter pattern for any SQLite driver
- Pre-built adapters: valTownAdapter, denoSqliteAdapter, betterSqlite3Adapter

tijs.org f35d81df

+12
.gitignore
··· 1 + # IDE 2 + .idea/ 3 + .vscode/ 4 + *.swp 5 + *.swo 6 + 7 + # OS 8 + .DS_Store 9 + Thumbs.db 10 + 11 + # Coverage 12 + coverage/
+19
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + All notable changes to this project will be documented in this file. 4 + 5 + ## [0.1.0] - 2025-11-27 6 + 7 + ### Added 8 + 9 + - Initial release 10 + - `OAuthStorage` interface for key-value storage with TTL support 11 + - `MemoryStorage` implementation for testing and development 12 + - `SQLiteStorage` implementation for production use 13 + - Adapter pattern for SQLite backends: 14 + - `valTownAdapter()` for Val.Town sqlite and libSQL/Turso 15 + - `denoSqliteAdapter()` for @db/sqlite (Deno native) 16 + - `betterSqlite3Adapter()` for better-sqlite3 (Node.js) 17 + - Automatic table creation and schema management 18 + - TTL-based expiration with cleanup method 19 + - Comprehensive test suite
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Tijs Teulings 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+123
README.md
··· 1 + # @tijs/atproto-storage 2 + 3 + Storage implementations for AT Protocol OAuth applications. Provides a simple 4 + key-value storage interface with implementations for in-memory and SQLite 5 + backends. 6 + 7 + ## Installation 8 + 9 + ```typescript 10 + import { 11 + MemoryStorage, 12 + SQLiteStorage, 13 + valTownAdapter, 14 + } from "jsr:@tijs/atproto-storage"; 15 + ``` 16 + 17 + ## Usage 18 + 19 + ### In-Memory Storage (Testing/Development) 20 + 21 + ```typescript 22 + import { MemoryStorage } from "jsr:@tijs/atproto-storage"; 23 + 24 + const storage = new MemoryStorage(); 25 + 26 + // Store with TTL (seconds) 27 + await storage.set("session:123", { did: "did:plc:abc" }, { ttl: 3600 }); 28 + 29 + // Retrieve 30 + const session = await storage.get("session:123"); 31 + 32 + // Delete 33 + await storage.delete("session:123"); 34 + ``` 35 + 36 + ### SQLite Storage (Production) 37 + 38 + SQLiteStorage works with any SQLite driver via adapters: 39 + 40 + #### Val.Town / libSQL / Turso 41 + 42 + ```typescript 43 + import { sqlite } from "https://esm.town/v/std/sqlite"; 44 + import { SQLiteStorage, valTownAdapter } from "jsr:@tijs/atproto-storage"; 45 + 46 + const storage = new SQLiteStorage(valTownAdapter(sqlite)); 47 + ``` 48 + 49 + #### Deno Native SQLite 50 + 51 + ```typescript 52 + import { Database } from "jsr:@db/sqlite"; 53 + import { denoSqliteAdapter, SQLiteStorage } from "jsr:@tijs/atproto-storage"; 54 + 55 + const db = new Database("storage.db"); 56 + const storage = new SQLiteStorage(denoSqliteAdapter(db)); 57 + ``` 58 + 59 + #### better-sqlite3 (Node.js) 60 + 61 + ```typescript 62 + import Database from "better-sqlite3"; 63 + import { betterSqlite3Adapter, SQLiteStorage } from "jsr:@tijs/atproto-storage"; 64 + 65 + const db = new Database("storage.db"); 66 + const storage = new SQLiteStorage(betterSqlite3Adapter(db)); 67 + ``` 68 + 69 + #### Custom Adapter 70 + 71 + ```typescript 72 + import { SQLiteAdapter, SQLiteStorage } from "jsr:@tijs/atproto-storage"; 73 + 74 + const customAdapter: SQLiteAdapter = { 75 + execute: async (sql, params) => { 76 + const result = await myDriver.query(sql, params); 77 + return result.rows; 78 + }, 79 + }; 80 + 81 + const storage = new SQLiteStorage(customAdapter); 82 + ``` 83 + 84 + ### Options 85 + 86 + ```typescript 87 + const storage = new SQLiteStorage(adapter, { 88 + tableName: "my_storage", // Default: "oauth_storage" 89 + logger: console, // Optional logger for debugging 90 + }); 91 + ``` 92 + 93 + ### Cleanup 94 + 95 + For SQLite storage, periodically clean up expired entries: 96 + 97 + ```typescript 98 + const deletedCount = await storage.cleanup(); 99 + ``` 100 + 101 + ## API 102 + 103 + ### OAuthStorage Interface 104 + 105 + ```typescript 106 + interface OAuthStorage { 107 + get<T = unknown>(key: string): Promise<T | null>; 108 + set<T = unknown>(key: string, value: T, options?: { ttl?: number }): Promise<void>; 109 + delete(key: string): Promise<void>; 110 + } 111 + ``` 112 + 113 + ### SQLiteAdapter Interface 114 + 115 + ```typescript 116 + interface SQLiteAdapter { 117 + execute(sql: string, params: unknown[]): Promise<unknown[][]>; 118 + } 119 + ``` 120 + 121 + ## License 122 + 123 + MIT
+26
deno.json
··· 1 + { 2 + "$schema": "https://jsr.io/schema/config-file.v1.json", 3 + "name": "@tijs/atproto-storage", 4 + "version": "0.1.0", 5 + "license": "MIT", 6 + "exports": "./mod.ts", 7 + "publish": { 8 + "include": ["mod.ts", "src/**/*.ts", "README.md", "LICENSE"], 9 + "exclude": ["**/*.test.ts"] 10 + }, 11 + "imports": { 12 + "@std/assert": "jsr:@std/assert@1.0.16" 13 + }, 14 + "compilerOptions": { 15 + "strict": true, 16 + "noImplicitAny": true 17 + }, 18 + "tasks": { 19 + "test": "deno test --allow-all src/", 20 + "check": "deno check mod.ts", 21 + "fmt": "deno fmt", 22 + "lint": "deno lint", 23 + "quality": "deno fmt && deno lint && deno check mod.ts", 24 + "ci": "deno task quality && deno task test" 25 + } 26 + }
+23
deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@std/assert@1.0.16": "1.0.16", 5 + "jsr:@std/internal@^1.0.12": "1.0.12" 6 + }, 7 + "jsr": { 8 + "@std/assert@1.0.16": { 9 + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", 10 + "dependencies": [ 11 + "jsr:@std/internal" 12 + ] 13 + }, 14 + "@std/internal@1.0.12": { 15 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 16 + } 17 + }, 18 + "workspace": { 19 + "dependencies": [ 20 + "jsr:@std/assert@1.0.16" 21 + ] 22 + } 23 + }
+59
mod.ts
··· 1 + /** 2 + * @module atproto-storage 3 + * 4 + * Storage implementations for AT Protocol OAuth applications. 5 + * 6 + * Provides a simple storage interface with implementations for: 7 + * - In-memory storage (for testing/development) 8 + * - SQLite storage (works with any SQLite driver via adapters) 9 + * 10 + * @example Val.Town / libSQL 11 + * ```typescript 12 + * import { sqlite } from "https://esm.town/v/std/sqlite"; 13 + * import { SQLiteStorage, valTownAdapter } from "@tijs/atproto-storage"; 14 + * 15 + * const storage = new SQLiteStorage(valTownAdapter(sqlite)); 16 + * ``` 17 + * 18 + * @example Deno native SQLite 19 + * ```typescript 20 + * import { Database } from "jsr:@db/sqlite"; 21 + * import { SQLiteStorage, denoSqliteAdapter } from "@tijs/atproto-storage"; 22 + * 23 + * const db = new Database("storage.db"); 24 + * const storage = new SQLiteStorage(denoSqliteAdapter(db)); 25 + * ``` 26 + * 27 + * @example Testing with MemoryStorage 28 + * ```typescript 29 + * import { MemoryStorage } from "@tijs/atproto-storage"; 30 + * 31 + * const storage = new MemoryStorage(); 32 + * ``` 33 + * 34 + * @example Custom SQLite adapter 35 + * ```typescript 36 + * import { SQLiteStorage, SQLiteAdapter } from "@tijs/atproto-storage"; 37 + * 38 + * const customAdapter: SQLiteAdapter = { 39 + * execute: async (sql, params) => myDriver.query(sql, params) 40 + * }; 41 + * const storage = new SQLiteStorage(customAdapter); 42 + * ``` 43 + */ 44 + 45 + // Types 46 + export type { Logger, OAuthStorage, SQLiteAdapter } from "./src/types.ts"; 47 + 48 + // Implementations 49 + export { MemoryStorage } from "./src/memory.ts"; 50 + export { SQLiteStorage } from "./src/sqlite.ts"; 51 + export type { SQLiteStorageOptions } from "./src/sqlite.ts"; 52 + 53 + // Adapters 54 + export { 55 + betterSqlite3Adapter, 56 + denoSqliteAdapter, 57 + valTownAdapter, 58 + } from "./src/adapters.ts"; 59 + export type { ExecutableDriver, PrepareDriver } from "./src/adapters.ts";
+106
src/adapters.ts
··· 1 + /** 2 + * Pre-built adapters for common SQLite drivers. 3 + * Each adapter converts a specific driver's API to the SQLiteAdapter interface. 4 + */ 5 + 6 + import type { SQLiteAdapter } from "./types.ts"; 7 + 8 + /** 9 + * Driver interface for Val.Town sqlite and libSQL/Turso client. 10 + * Both use the same execute({ sql, args }) pattern. 11 + */ 12 + export interface ExecutableDriver { 13 + execute( 14 + query: { sql: string; args: unknown[] }, 15 + ): Promise<{ rows: unknown[][] }>; 16 + } 17 + 18 + /** 19 + * Driver interface for @db/sqlite (Deno native) and similar prepare-based drivers. 20 + */ 21 + export interface PrepareDriver { 22 + prepare(sql: string): { 23 + all<T = Record<string, unknown>>(...params: unknown[]): T[]; 24 + }; 25 + } 26 + 27 + /** 28 + * Adapter for Val.Town sqlite and libSQL/Turso client. 29 + * These drivers share the same execute({ sql, args }) API pattern. 30 + * 31 + * @example Val.Town 32 + * ```typescript 33 + * import { sqlite } from "https://esm.town/v/std/sqlite"; 34 + * import { SQLiteStorage, valTownAdapter } from "@tijs/atproto-storage"; 35 + * 36 + * const storage = new SQLiteStorage(valTownAdapter(sqlite)); 37 + * ``` 38 + * 39 + * @example libSQL/Turso 40 + * ```typescript 41 + * import { createClient } from "@libsql/client"; 42 + * import { SQLiteStorage, valTownAdapter } from "@tijs/atproto-storage"; 43 + * 44 + * const client = createClient({ url: "libsql://..." }); 45 + * const storage = new SQLiteStorage(valTownAdapter(client)); 46 + * ``` 47 + */ 48 + export function valTownAdapter(driver: ExecutableDriver): SQLiteAdapter { 49 + return { 50 + execute: async (sql: string, params: unknown[]): Promise<unknown[][]> => { 51 + const result = await driver.execute({ sql, args: params }); 52 + return result.rows; 53 + }, 54 + }; 55 + } 56 + 57 + /** 58 + * Adapter for @db/sqlite (Deno native SQLite). 59 + * Converts the synchronous prepare/all pattern to the async adapter interface. 60 + * 61 + * @example 62 + * ```typescript 63 + * import { Database } from "jsr:@db/sqlite"; 64 + * import { SQLiteStorage, denoSqliteAdapter } from "@tijs/atproto-storage"; 65 + * 66 + * const db = new Database("storage.db"); 67 + * const storage = new SQLiteStorage(denoSqliteAdapter(db)); 68 + * ``` 69 + */ 70 + export function denoSqliteAdapter(db: PrepareDriver): SQLiteAdapter { 71 + return { 72 + execute: (sql: string, params: unknown[]): Promise<unknown[][]> => { 73 + const stmt = db.prepare(sql); 74 + const rows = stmt.all(...params); 75 + // Convert object rows to array rows (column order from Object.values) 76 + return Promise.resolve( 77 + rows.map((row) => Object.values(row as Record<string, unknown>)), 78 + ); 79 + }, 80 + }; 81 + } 82 + 83 + /** 84 + * Adapter for better-sqlite3 (Node.js). 85 + * Same pattern as Deno native but for Node environment. 86 + * 87 + * @example 88 + * ```typescript 89 + * import Database from "better-sqlite3"; 90 + * import { SQLiteStorage, betterSqlite3Adapter } from "@tijs/atproto-storage"; 91 + * 92 + * const db = new Database("storage.db"); 93 + * const storage = new SQLiteStorage(betterSqlite3Adapter(db)); 94 + * ``` 95 + */ 96 + export function betterSqlite3Adapter(db: PrepareDriver): SQLiteAdapter { 97 + return { 98 + execute: (sql: string, params: unknown[]): Promise<unknown[][]> => { 99 + const stmt = db.prepare(sql); 100 + const rows = stmt.all(...params); 101 + return Promise.resolve( 102 + rows.map((row) => Object.values(row as Record<string, unknown>)), 103 + ); 104 + }, 105 + }; 106 + }
+87
src/memory.ts
··· 1 + /** 2 + * In-memory storage implementation for OAuth sessions. 3 + * Perfect for testing and development. 4 + */ 5 + 6 + import type { OAuthStorage } from "./types.ts"; 7 + 8 + interface StorageEntry { 9 + value: unknown; 10 + expiresAt?: number; 11 + } 12 + 13 + /** 14 + * In-memory storage for OAuth sessions and tokens. 15 + * 16 + * Features: 17 + * - Automatic TTL expiration 18 + * - No external dependencies 19 + * - Perfect for testing and single-process deployments 20 + * 21 + * Note: Data is lost when the process restarts. 22 + * For production, use SQLiteStorage or another persistent implementation. 23 + * 24 + * @example 25 + * ```typescript 26 + * const storage = new MemoryStorage(); 27 + * 28 + * // Store with TTL 29 + * await storage.set("session:123", { did: "did:plc:abc" }, { ttl: 3600 }); 30 + * 31 + * // Retrieve 32 + * const session = await storage.get("session:123"); 33 + * 34 + * // Delete 35 + * await storage.delete("session:123"); 36 + * ``` 37 + */ 38 + export class MemoryStorage implements OAuthStorage { 39 + private data = new Map<string, StorageEntry>(); 40 + 41 + get<T = unknown>(key: string): Promise<T | null> { 42 + const item = this.data.get(key); 43 + if (!item) return Promise.resolve(null); 44 + 45 + // Check expiration 46 + if (item.expiresAt && item.expiresAt <= Date.now()) { 47 + this.data.delete(key); 48 + return Promise.resolve(null); 49 + } 50 + 51 + return Promise.resolve(item.value as T); 52 + } 53 + 54 + set<T = unknown>( 55 + key: string, 56 + value: T, 57 + options?: { ttl?: number }, 58 + ): Promise<void> { 59 + const expiresAt = options?.ttl 60 + ? Date.now() + (options.ttl * 1000) 61 + : undefined; 62 + 63 + this.data.set(key, { value, expiresAt }); 64 + return Promise.resolve(); 65 + } 66 + 67 + delete(key: string): Promise<void> { 68 + this.data.delete(key); 69 + return Promise.resolve(); 70 + } 71 + 72 + /** 73 + * Clear all entries from storage. 74 + * Useful for testing. 75 + */ 76 + clear(): void { 77 + this.data.clear(); 78 + } 79 + 80 + /** 81 + * Get the number of entries in storage. 82 + * Useful for testing. 83 + */ 84 + get size(): number { 85 + return this.data.size; 86 + } 87 + }
+232
src/sqlite.ts
··· 1 + /** 2 + * SQLite storage implementation for OAuth sessions. 3 + * Works with any SQLite driver via adapters. 4 + */ 5 + 6 + import type { Logger, OAuthStorage, SQLiteAdapter } from "./types.ts"; 7 + 8 + /** No-op logger for production use */ 9 + const noopLogger: Logger = { 10 + log: () => {}, 11 + warn: () => {}, 12 + error: () => {}, 13 + }; 14 + 15 + /** 16 + * Configuration options for SQLiteStorage 17 + */ 18 + export 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 + */ 60 + export 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 + }
+392
src/storage.test.ts
··· 1 + import { assertEquals, assertExists } from "@std/assert"; 2 + import { MemoryStorage } from "./memory.ts"; 3 + import { SQLiteStorage } from "./sqlite.ts"; 4 + import { valTownAdapter } from "./adapters.ts"; 5 + import type { SQLiteAdapter } from "./types.ts"; 6 + 7 + // Mock SQLite database that implements the ExecutableDriver interface 8 + // (used with valTownAdapter to create an SQLiteAdapter) 9 + class MockExecutableDriver { 10 + private tables = new Map<string, Map<string, unknown[]>>(); 11 + 12 + execute( 13 + query: { sql: string; args: unknown[] }, 14 + ): Promise<{ rows: unknown[][] }> { 15 + const sql = query.sql.trim(); 16 + 17 + // CREATE TABLE 18 + if (sql.toUpperCase().startsWith("CREATE TABLE")) { 19 + const match = sql.match(/CREATE TABLE IF NOT EXISTS (\w+)/i); 20 + if (match) { 21 + const tableName = match[1]; 22 + if (!this.tables.has(tableName)) { 23 + this.tables.set(tableName, new Map()); 24 + } 25 + } 26 + return Promise.resolve({ rows: [] }); 27 + } 28 + 29 + // CREATE INDEX 30 + if (sql.toUpperCase().startsWith("CREATE INDEX")) { 31 + return Promise.resolve({ rows: [] }); 32 + } 33 + 34 + // INSERT 35 + if (sql.toUpperCase().startsWith("INSERT")) { 36 + const match = sql.match(/INSERT INTO (\w+)/i); 37 + if (match) { 38 + const tableName = match[1]; 39 + const table = this.tables.get(tableName) || new Map(); 40 + const key = query.args[0] as string; 41 + table.set(key, query.args); 42 + this.tables.set(tableName, table); 43 + } 44 + return Promise.resolve({ rows: [] }); 45 + } 46 + 47 + // SELECT 48 + if (sql.toUpperCase().startsWith("SELECT")) { 49 + const countMatch = sql.match(/SELECT COUNT\(\*\) FROM (\w+)/i); 50 + if (countMatch) { 51 + return Promise.resolve({ rows: [[0]] }); 52 + } 53 + 54 + const match = sql.match(/FROM (\w+)/i); 55 + if (match) { 56 + const tableName = match[1]; 57 + const table = this.tables.get(tableName); 58 + if (table) { 59 + const key = query.args[0] as string; 60 + const row = table.get(key); 61 + if (row) { 62 + // Return value and expires_at (indices 1 and 2) 63 + return Promise.resolve({ 64 + rows: [[row[1], row[2]]], 65 + }); 66 + } 67 + } 68 + } 69 + return Promise.resolve({ rows: [] }); 70 + } 71 + 72 + // DELETE 73 + if (sql.toUpperCase().startsWith("DELETE")) { 74 + const match = sql.match(/FROM (\w+)/i); 75 + if (match) { 76 + const tableName = match[1]; 77 + const table = this.tables.get(tableName); 78 + if (table) { 79 + const key = query.args[0] as string; 80 + table.delete(key); 81 + } 82 + } 83 + return Promise.resolve({ rows: [] }); 84 + } 85 + 86 + return Promise.resolve({ rows: [] }); 87 + } 88 + } 89 + 90 + // Direct mock adapter for testing (without going through valTownAdapter) 91 + function createMockAdapter(): SQLiteAdapter { 92 + const tables = new Map<string, Map<string, unknown[]>>(); 93 + 94 + return { 95 + execute: (sql: string, params: unknown[]): Promise<unknown[][]> => { 96 + const trimmedSql = sql.trim(); 97 + 98 + // CREATE TABLE 99 + if (trimmedSql.toUpperCase().startsWith("CREATE TABLE")) { 100 + const match = trimmedSql.match(/CREATE TABLE IF NOT EXISTS (\w+)/i); 101 + if (match) { 102 + const tableName = match[1]; 103 + if (!tables.has(tableName)) { 104 + tables.set(tableName, new Map()); 105 + } 106 + } 107 + return Promise.resolve([]); 108 + } 109 + 110 + // CREATE INDEX 111 + if (trimmedSql.toUpperCase().startsWith("CREATE INDEX")) { 112 + return Promise.resolve([]); 113 + } 114 + 115 + // INSERT 116 + if (trimmedSql.toUpperCase().startsWith("INSERT")) { 117 + const match = trimmedSql.match(/INSERT INTO (\w+)/i); 118 + if (match) { 119 + const tableName = match[1]; 120 + const table = tables.get(tableName) || new Map(); 121 + const key = params[0] as string; 122 + table.set(key, params); 123 + tables.set(tableName, table); 124 + } 125 + return Promise.resolve([]); 126 + } 127 + 128 + // SELECT 129 + if (trimmedSql.toUpperCase().startsWith("SELECT")) { 130 + const countMatch = trimmedSql.match(/SELECT COUNT\(\*\) FROM (\w+)/i); 131 + if (countMatch) { 132 + return Promise.resolve([[0]]); 133 + } 134 + 135 + const match = trimmedSql.match(/FROM (\w+)/i); 136 + if (match) { 137 + const tableName = match[1]; 138 + const table = tables.get(tableName); 139 + if (table) { 140 + const key = params[0] as string; 141 + const row = table.get(key); 142 + if (row) { 143 + // Return value and expires_at (indices 1 and 2) 144 + return Promise.resolve([[row[1], row[2]]]); 145 + } 146 + } 147 + } 148 + return Promise.resolve([]); 149 + } 150 + 151 + // DELETE 152 + if (trimmedSql.toUpperCase().startsWith("DELETE")) { 153 + const match = trimmedSql.match(/FROM (\w+)/i); 154 + if (match) { 155 + const tableName = match[1]; 156 + const table = tables.get(tableName); 157 + if (table) { 158 + const key = params[0] as string; 159 + table.delete(key); 160 + } 161 + } 162 + return Promise.resolve([]); 163 + } 164 + 165 + return Promise.resolve([]); 166 + }, 167 + }; 168 + } 169 + 170 + // ============ MemoryStorage Tests ============ 171 + 172 + Deno.test("MemoryStorage - basic operations", async (t) => { 173 + const storage = new MemoryStorage(); 174 + 175 + await t.step("set and get value", async () => { 176 + await storage.set("key1", { foo: "bar" }); 177 + const result = await storage.get<{ foo: string }>("key1"); 178 + assertExists(result); 179 + assertEquals(result.foo, "bar"); 180 + }); 181 + 182 + await t.step("get non-existent key returns null", async () => { 183 + const result = await storage.get("nonexistent"); 184 + assertEquals(result, null); 185 + }); 186 + 187 + await t.step("delete removes value", async () => { 188 + await storage.set("key2", "value"); 189 + await storage.delete("key2"); 190 + const result = await storage.get("key2"); 191 + assertEquals(result, null); 192 + }); 193 + 194 + await t.step("overwrite existing value", async () => { 195 + await storage.set("key3", "first"); 196 + await storage.set("key3", "second"); 197 + const result = await storage.get("key3"); 198 + assertEquals(result, "second"); 199 + }); 200 + }); 201 + 202 + Deno.test("MemoryStorage - TTL expiration", async (t) => { 203 + const storage = new MemoryStorage(); 204 + 205 + await t.step("value available before TTL", async () => { 206 + await storage.set("ttl-key", "value", { ttl: 10 }); // 10 seconds 207 + const result = await storage.get("ttl-key"); 208 + assertEquals(result, "value"); 209 + }); 210 + 211 + await t.step("value expired after TTL", async () => { 212 + // Set with very short TTL 213 + await storage.set("expired-key", "value", { ttl: 0.001 }); // 1ms 214 + // Wait long enough to ensure expiration 215 + await new Promise((r) => setTimeout(r, 50)); 216 + const result = await storage.get("expired-key"); 217 + assertEquals(result, null); 218 + }); 219 + 220 + await t.step("value without TTL never expires", async () => { 221 + await storage.set("no-ttl", "value"); 222 + const result = await storage.get("no-ttl"); 223 + assertEquals(result, "value"); 224 + }); 225 + }); 226 + 227 + Deno.test("MemoryStorage - helper methods", async (t) => { 228 + await t.step("clear removes all entries", async () => { 229 + const storage = new MemoryStorage(); 230 + await storage.set("a", 1); 231 + await storage.set("b", 2); 232 + assertEquals(storage.size, 2); 233 + 234 + storage.clear(); 235 + assertEquals(storage.size, 0); 236 + }); 237 + 238 + await t.step("size reflects entry count", async () => { 239 + const storage = new MemoryStorage(); 240 + assertEquals(storage.size, 0); 241 + 242 + await storage.set("a", 1); 243 + assertEquals(storage.size, 1); 244 + 245 + await storage.set("b", 2); 246 + assertEquals(storage.size, 2); 247 + 248 + await storage.delete("a"); 249 + assertEquals(storage.size, 1); 250 + }); 251 + }); 252 + 253 + Deno.test("MemoryStorage - complex values", async (t) => { 254 + const storage = new MemoryStorage(); 255 + 256 + await t.step("stores objects", async () => { 257 + const obj = { nested: { deep: { value: 123 } } }; 258 + await storage.set("obj", obj); 259 + const result = await storage.get<typeof obj>("obj"); 260 + assertEquals(result, obj); 261 + }); 262 + 263 + await t.step("stores arrays", async () => { 264 + const arr = [1, 2, 3, { four: 4 }]; 265 + await storage.set("arr", arr); 266 + const result = await storage.get<typeof arr>("arr"); 267 + assertEquals(result, arr); 268 + }); 269 + 270 + await t.step("stores null", async () => { 271 + await storage.set("null", null); 272 + const result = await storage.get("null"); 273 + assertEquals(result, null); 274 + }); 275 + }); 276 + 277 + // ============ SQLiteStorage Tests ============ 278 + 279 + Deno.test("SQLiteStorage - basic operations with direct adapter", async (t) => { 280 + const adapter = createMockAdapter(); 281 + const storage = new SQLiteStorage(adapter); 282 + 283 + await t.step("set and get value", async () => { 284 + await storage.set("key1", { foo: "bar" }); 285 + const result = await storage.get<{ foo: string }>("key1"); 286 + assertExists(result); 287 + assertEquals(result.foo, "bar"); 288 + }); 289 + 290 + await t.step("get non-existent key returns null", async () => { 291 + const result = await storage.get("nonexistent"); 292 + assertEquals(result, null); 293 + }); 294 + 295 + await t.step("delete removes value", async () => { 296 + await storage.set("key2", "value"); 297 + await storage.delete("key2"); 298 + const result = await storage.get("key2"); 299 + assertEquals(result, null); 300 + }); 301 + }); 302 + 303 + Deno.test("SQLiteStorage - with valTownAdapter", async (t) => { 304 + const mockDriver = new MockExecutableDriver(); 305 + const adapter = valTownAdapter(mockDriver); 306 + const storage = new SQLiteStorage(adapter); 307 + 308 + await t.step("set and get value", async () => { 309 + await storage.set("key1", { foo: "bar" }); 310 + const result = await storage.get<{ foo: string }>("key1"); 311 + assertExists(result); 312 + assertEquals(result.foo, "bar"); 313 + }); 314 + 315 + await t.step("get non-existent key returns null", async () => { 316 + const result = await storage.get("nonexistent"); 317 + assertEquals(result, null); 318 + }); 319 + 320 + await t.step("delete removes value", async () => { 321 + await storage.set("key2", "value"); 322 + await storage.delete("key2"); 323 + const result = await storage.get("key2"); 324 + assertEquals(result, null); 325 + }); 326 + }); 327 + 328 + Deno.test("SQLiteStorage - custom options", async (t) => { 329 + await t.step("accepts custom table name", async () => { 330 + const adapter = createMockAdapter(); 331 + const storage = new SQLiteStorage(adapter, { tableName: "custom_table" }); 332 + await storage.set("key", "value"); 333 + const result = await storage.get("key"); 334 + assertEquals(result, "value"); 335 + }); 336 + 337 + await t.step("accepts custom logger", async () => { 338 + const logs: string[] = []; 339 + const logger = { 340 + log: (...args: unknown[]) => logs.push(args.join(" ")), 341 + warn: () => {}, 342 + error: () => {}, 343 + }; 344 + 345 + const adapter = createMockAdapter(); 346 + const storage = new SQLiteStorage(adapter, { logger }); 347 + await storage.set("key", "value"); 348 + 349 + assertEquals(logs.length > 0, true); 350 + }); 351 + }); 352 + 353 + Deno.test("SQLiteStorage - TTL handling", async (t) => { 354 + const adapter = createMockAdapter(); 355 + const storage = new SQLiteStorage(adapter); 356 + 357 + await t.step("sets TTL when provided", async () => { 358 + await storage.set("ttl-key", "value", { ttl: 3600 }); 359 + const result = await storage.get("ttl-key"); 360 + assertEquals(result, "value"); 361 + }); 362 + 363 + await t.step("no TTL when not provided", async () => { 364 + await storage.set("no-ttl", "value"); 365 + const result = await storage.get("no-ttl"); 366 + assertEquals(result, "value"); 367 + }); 368 + }); 369 + 370 + // ============ Adapter Tests ============ 371 + 372 + Deno.test("valTownAdapter - transforms execute signature", async () => { 373 + let capturedSql = ""; 374 + let capturedParams: unknown[] = []; 375 + 376 + const mockDriver = { 377 + execute: (query: { sql: string; args: unknown[] }) => { 378 + capturedSql = query.sql; 379 + capturedParams = query.args; 380 + return Promise.resolve({ rows: [["test-value", null]] }); 381 + }, 382 + }; 383 + 384 + const adapter = valTownAdapter(mockDriver); 385 + const result = await adapter.execute("SELECT * FROM test WHERE id = ?", [ 386 + 123, 387 + ]); 388 + 389 + assertEquals(capturedSql, "SELECT * FROM test WHERE id = ?"); 390 + assertEquals(capturedParams, [123]); 391 + assertEquals(result, [["test-value", null]]); 392 + });
+70
src/types.ts
··· 1 + /** 2 + * Storage interface for OAuth sessions and tokens. 3 + * Compatible with @tijs/oauth-client-deno, @tijs/hono-oauth-sessions, 4 + * and @tijs/atproto-sessions. 5 + */ 6 + export interface OAuthStorage { 7 + /** 8 + * Retrieve a value from storage 9 + * @param key - Storage key 10 + * @returns The value, or null if not found or expired 11 + */ 12 + get<T = unknown>(key: string): Promise<T | null>; 13 + 14 + /** 15 + * Store a value in storage with optional TTL 16 + * @param key - Storage key 17 + * @param value - Value to store (will be JSON serialized) 18 + * @param options - Optional settings 19 + * @param options.ttl - Time-to-live in seconds 20 + */ 21 + set<T = unknown>( 22 + key: string, 23 + value: T, 24 + options?: { ttl?: number }, 25 + ): Promise<void>; 26 + 27 + /** 28 + * Delete a value from storage 29 + * @param key - Storage key 30 + */ 31 + delete(key: string): Promise<void>; 32 + } 33 + 34 + /** 35 + * Minimal SQLite adapter interface. 36 + * Adapts any SQLite driver to work with SQLiteStorage. 37 + * 38 + * Use one of the pre-built adapters or implement your own: 39 + * - `valTownAdapter()` - For Val.Town sqlite and libSQL/Turso 40 + * - `denoSqliteAdapter()` - For @db/sqlite (Deno native) 41 + * - `betterSqlite3Adapter()` - For better-sqlite3 (Node.js) 42 + * 43 + * @example Custom adapter 44 + * ```typescript 45 + * const customAdapter: SQLiteAdapter = { 46 + * execute: async (sql, params) => { 47 + * const result = await myDriver.query(sql, params); 48 + * return result.rows; 49 + * } 50 + * }; 51 + * ``` 52 + */ 53 + export interface SQLiteAdapter { 54 + /** 55 + * Execute a SQL query with parameters. 56 + * @param sql - SQL query string with ? placeholders 57 + * @param params - Parameter values for placeholders 58 + * @returns Array of rows, where each row is an array of column values 59 + */ 60 + execute(sql: string, params: unknown[]): Promise<unknown[][]>; 61 + } 62 + 63 + /** 64 + * Logger interface for debugging storage operations 65 + */ 66 + export interface Logger { 67 + log(...args: unknown[]): void; 68 + warn(...args: unknown[]): void; 69 + error(...args: unknown[]): void; 70 + }