Storage implementations for AT Protocol OAuth applications. Provides a simple key-value storage interface with implementations for in-memory and SQLite backends.
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}