Storage implementations for AT Protocol OAuth applications. Provides a simple key-value storage interface with implementations for in-memory and SQLite backends.
1import { assertEquals, assertExists } from "@std/assert";
2import { MemoryStorage } from "./memory.ts";
3import { SQLiteStorage } from "./sqlite.ts";
4import { sqliteAdapter } from "./adapters.ts";
5import type { SQLiteAdapter } from "./types.ts";
6
7// Mock SQLite database that implements the ExecutableDriver interface
8// (used with sqliteAdapter to create an SQLiteAdapter)
9class 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)
91function 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
172Deno.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
202Deno.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
227Deno.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
253Deno.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
279Deno.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
303Deno.test("SQLiteStorage - with sqliteAdapter", async (t) => {
304 const mockDriver = new MockExecutableDriver();
305 const adapter = sqliteAdapter(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
328Deno.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
353Deno.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
372Deno.test("sqliteAdapter - 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 = sqliteAdapter(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});