+12
.gitignore
+12
.gitignore
+19
CHANGELOG.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}