chore: refactored server package to use sqlite instead of redis 7348ada4
Steve · 2026-03-07 19:52 10 file(s) · +105 −58
.gitignore +3 −0
33 33
# Finder (MacOS) folder config
34 34
.DS_Store
35 35
36 +
# SQLite data
37 +
data/
38 +
36 39
# Bun lockfile - keep but binary cache
37 40
bun.lockb
38 41
plans/
packages/server/.env.example +1 −1
1 1
CLIENT_URL=https://your-domain.com
2 2
CLIENT_NAME=Sequoia
3 3
PORT=3000
4 -
REDIS_URL=redis://redis:6379
4 +
DATABASE_PATH=./data/sequoia.db
5 5
6 6
# Theme overrides (optional)
7 7
# THEME_ACCENT_COLOR=#3A5A40
packages/server/docker-compose.yml +3 −8
7 7
      - CLIENT_URL=${CLIENT_URL}
8 8
      - CLIENT_NAME=${CLIENT_NAME:-Sequoia}
9 9
      - PORT=${PORT:-3000}
10 -
      - REDIS_URL=redis://redis:6379
10 +
      - DATABASE_PATH=${DATABASE_PATH:-/app/data/sequoia.db}
11 11
      - THEME_ACCENT_COLOR=${THEME_ACCENT_COLOR:-}
12 12
      - THEME_BG_COLOR=${THEME_BG_COLOR:-}
13 13
      - THEME_FG_COLOR=${THEME_FG_COLOR:-}
20 20
      - THEME_DARK_BORDER_COLOR=${THEME_DARK_BORDER_COLOR:-}
21 21
      - THEME_DARK_ERROR_COLOR=${THEME_DARK_ERROR_COLOR:-}
22 22
      - THEME_CSS_PATH=${THEME_CSS_PATH:-}
23 -
    depends_on:
24 -
      - redis
25 -
26 -
  redis:
27 -
    image: redis:7
28 23
    volumes:
29 -
      - redis-data:/data
24 +
      - sequoia-data:/app/data
30 25
31 26
volumes:
32 -
  redis-data:
27 +
  sequoia-data:
packages/server/src/env.ts +2 −2
2 2
	CLIENT_URL: string;
3 3
	CLIENT_NAME: string;
4 4
	PORT: number;
5 -
	REDIS_URL: string;
5 +
	DATABASE_PATH: string;
6 6
}
7 7
8 8
export function loadEnv(): Env {
15 15
		CLIENT_URL: CLIENT_URL.replace(/\/+$/, ""),
16 16
		CLIENT_NAME: process.env.CLIENT_NAME || "Sequoia",
17 17
		PORT: Number(process.env.PORT) || 3000,
18 -
		REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379",
18 +
		DATABASE_PATH: process.env.DATABASE_PATH || "./data/sequoia.db",
19 19
	};
20 20
}
packages/server/src/index.ts +5 −5
1 1
import { Hono } from "hono";
2 2
import { cors } from "hono/cors";
3 -
import { RedisClient } from "bun";
4 3
import { loadEnv } from "./env";
5 4
import type { Env } from "./env";
5 +
import { openDatabase } from "./lib/db";
6 6
import auth from "./routes/auth";
7 7
import subscribe from "./routes/subscribe";
8 8
9 9
const env = loadEnv();
10 10
11 -
const redis = new RedisClient(env.REDIS_URL);
11 +
const db = openDatabase(env.DATABASE_PATH);
12 12
13 -
type Variables = { env: Env; redis: typeof redis };
13 +
type Variables = { env: Env; db: typeof db };
14 14
15 15
const app = new Hono<{ Variables: Variables }>();
16 16
17 -
// Inject env and redis into all routes
17 +
// Inject env and db into all routes
18 18
app.use("*", async (c, next) => {
19 19
	c.set("env", env);
20 -
	c.set("redis", redis);
20 +
	c.set("db", db);
21 21
	await next();
22 22
});
23 23
packages/server/src/lib/db.ts (added) +53 −0
1 +
import { Database } from "bun:sqlite";
2 +
import { mkdirSync } from "node:fs";
3 +
import { dirname } from "node:path";
4 +
5 +
export function openDatabase(path: string): Database {
6 +
	mkdirSync(dirname(path), { recursive: true });
7 +
8 +
	const db = new Database(path);
9 +
	db.run("PRAGMA journal_mode = WAL");
10 +
	db.run(`
11 +
		CREATE TABLE IF NOT EXISTS kv (
12 +
			key   TEXT PRIMARY KEY,
13 +
			value TEXT NOT NULL,
14 +
			expires_at INTEGER
15 +
		)
16 +
	`);
17 +
	return db;
18 +
}
19 +
20 +
export function kvGet(db: Database, key: string): string | undefined {
21 +
	const row = db
22 +
		.query<{ value: string; expires_at: number | null }, [string]>(
23 +
			"SELECT value, expires_at FROM kv WHERE key = ?",
24 +
		)
25 +
		.get(key);
26 +
27 +
	if (!row) return undefined;
28 +
29 +
	if (row.expires_at !== null && row.expires_at <= Date.now()) {
30 +
		db.run("DELETE FROM kv WHERE key = ?", [key]);
31 +
		return undefined;
32 +
	}
33 +
34 +
	return row.value;
35 +
}
36 +
37 +
export function kvSet(
38 +
	db: Database,
39 +
	key: string,
40 +
	value: string,
41 +
	ttlSeconds?: number,
42 +
): void {
43 +
	const expiresAt =
44 +
		ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null;
45 +
	db.run(
46 +
		"INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)",
47 +
		[key, value, expiresAt],
48 +
	);
49 +
}
50 +
51 +
export function kvDel(db: Database, key: string): void {
52 +
	db.run("DELETE FROM kv WHERE key = ?", [key]);
53 +
}
packages/server/src/lib/oauth-client.ts +5 −5
1 1
import { JoseKey } from "@atproto/jwk-jose";
2 2
import { OAuthClient } from "@atproto/oauth-client";
3 3
import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver";
4 -
import type { RedisClient } from "bun";
5 -
import { createStateStore, createSessionStore } from "./redis-stores";
4 +
import type { Database } from "bun:sqlite";
5 +
import { createStateStore, createSessionStore } from "./stores";
6 6
7 7
export const OAUTH_SCOPE =
8 8
	"atproto repo:site.standard.graph.subscription?action=create&action=delete";
9 9
10 10
export function createOAuthClient(
11 -
	redis: RedisClient,
11 +
	db: Database,
12 12
	clientUrl: string,
13 13
	clientName = "Sequoia",
14 14
) {
47 47
			},
48 48
			requestLock: <T>(_name: string, fn: () => T | PromiseLike<T>) => fn(),
49 49
		},
50 -
		stateStore: createStateStore(redis),
51 -
		sessionStore: createSessionStore(redis),
50 +
		stateStore: createStateStore(db),
51 +
		sessionStore: createSessionStore(db),
52 52
	});
53 53
}
packages/server/src/lib/redis-stores.ts → packages/server/src/lib/stores.ts +10 −13
5 5
	SessionStore,
6 6
	StateStore,
7 7
} from "@atproto/oauth-client";
8 -
import { RedisClient } from "bun";
8 +
import type { Database } from "bun:sqlite";
9 +
import { kvGet, kvSet, kvDel } from "./db";
9 10
10 11
type SerializedStateData = Omit<InternalStateData, "dpopKey"> & {
11 12
	dpopJwk: Record<string, unknown>;
25 26
	return JoseKey.fromJWK(jwk) as unknown as Key;
26 27
}
27 28
28 -
export function createStateStore(redis: RedisClient, ttl = 600): StateStore {
29 +
export function createStateStore(db: Database, ttl = 600): StateStore {
29 30
	return {
30 31
		async set(key, { dpopKey, ...rest }) {
31 32
			const data: SerializedStateData = {
32 33
				...rest,
33 34
				dpopJwk: serializeKey(dpopKey),
34 35
			};
35 -
			const redisKey = `oauth_state:${key}`;
36 -
			await redis.set(redisKey, JSON.stringify(data));
37 -
			await redis.expire(redisKey, ttl);
36 +
			kvSet(db, `oauth_state:${key}`, JSON.stringify(data), ttl);
38 37
		},
39 38
		async get(key) {
40 -
			const raw = await redis.get(`oauth_state:${key}`);
39 +
			const raw = kvGet(db, `oauth_state:${key}`);
41 40
			if (!raw) return undefined;
42 41
			const { dpopJwk, ...rest }: SerializedStateData = JSON.parse(raw);
43 42
			const dpopKey = await deserializeKey(dpopJwk);
44 43
			return { ...rest, dpopKey };
45 44
		},
46 45
		async del(key) {
47 -
			await redis.del(`oauth_state:${key}`);
46 +
			kvDel(db, `oauth_state:${key}`);
48 47
		},
49 48
	};
50 49
}
51 50
52 51
export function createSessionStore(
53 -
	redis: RedisClient,
52 +
	db: Database,
54 53
	ttl = 60 * 60 * 24 * 14,
55 54
): SessionStore {
56 55
	return {
59 58
				...rest,
60 59
				dpopJwk: serializeKey(dpopKey),
61 60
			};
62 -
			const redisKey = `oauth_session:${sub}`;
63 -
			await redis.set(redisKey, JSON.stringify(data));
64 -
			await redis.expire(redisKey, ttl);
61 +
			kvSet(db, `oauth_session:${sub}`, JSON.stringify(data), ttl);
65 62
		},
66 63
		async get(sub) {
67 -
			const raw = await redis.get(`oauth_session:${sub}`);
64 +
			const raw = kvGet(db, `oauth_session:${sub}`);
68 65
			if (!raw) return undefined;
69 66
			const { dpopJwk, ...rest }: SerializedSession = JSON.parse(raw);
70 67
			const dpopKey = await deserializeKey(dpopJwk);
71 68
			return { ...rest, dpopKey };
72 69
		},
73 70
		async del(sub) {
74 -
			await redis.del(`oauth_session:${sub}`);
71 +
			kvDel(db, `oauth_session:${sub}`);
75 72
		},
76 73
	};
77 74
}
packages/server/src/routes/auth.ts +15 −16
1 1
import { Hono } from "hono";
2 -
import type { RedisClient } from "bun";
2 +
import type { Database } from "bun:sqlite";
3 3
import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client";
4 +
import { kvGet, kvSet, kvDel } from "../lib/db";
4 5
import {
5 6
	getSessionDid,
6 7
	setSessionCookie,
10 11
} from "../lib/session";
11 12
import type { Env } from "../env";
12 13
13 -
type Variables = { env: Env; redis: RedisClient };
14 +
type Variables = { env: Env; db: Database };
14 15
15 16
const auth = new Hono<{ Variables: Variables }>();
16 17
37 38
// Start OAuth login flow
38 39
auth.get("/login", async (c) => {
39 40
	const env = c.get("env");
40 -
	const redis = c.get("redis");
41 +
	const db = c.get("db");
41 42
42 43
	try {
43 44
		const handle = c.req.query("handle");
45 46
			return c.redirect(`${env.CLIENT_URL}/?error=missing_handle`);
46 47
		}
47 48
48 -
		const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME);
49 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
49 50
		const authUrl = await client.authorize(handle, {
50 51
			scope: OAUTH_SCOPE,
51 52
		});
60 61
// OAuth callback handler
61 62
auth.get("/callback", async (c) => {
62 63
	const env = c.get("env");
63 -
	const redis = c.get("redis");
64 +
	const db = c.get("db");
64 65
65 66
	try {
66 67
		const params = new URLSearchParams(c.req.url.split("?")[1] || "");
73 74
			);
74 75
		}
75 76
76 -
		const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME);
77 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
77 78
		const { session } = await client.callback(params);
78 79
79 80
		// Resolve handle from DID
85 86
			// Handle resolution is best-effort
86 87
		}
87 88
88 -
		// Store handle in Redis alongside the session for quick lookup
89 +
		// Store handle alongside the session for quick lookup
89 90
		if (handle) {
90 -
			const key = `oauth_handle:${session.did}`;
91 -
			await redis.set(key, handle);
92 -
			await redis.expire(key, 60 * 60 * 24 * 14);
91 +
			kvSet(db, `oauth_handle:${session.did}`, handle, 60 * 60 * 24 * 14);
93 92
		}
94 93
95 94
		setSessionCookie(c, session.did, env.CLIENT_URL);
108 107
// Logout endpoint
109 108
auth.post("/logout", async (c) => {
110 109
	const env = c.get("env");
111 -
	const redis = c.get("redis");
110 +
	const db = c.get("db");
112 111
	const did = getSessionDid(c);
113 112
114 113
	if (did) {
115 114
		try {
116 -
			const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME);
115 +
			const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
117 116
			await client.revoke(did);
118 117
		} catch (error) {
119 118
			console.error("Revoke error:", error);
120 119
		}
121 -
		await redis.del(`oauth_handle:${did}`);
120 +
		kvDel(db, `oauth_handle:${did}`);
122 121
	}
123 122
124 123
	clearSessionCookie(c, env.CLIENT_URL);
128 127
// Check auth status
129 128
auth.get("/status", async (c) => {
130 129
	const env = c.get("env");
131 -
	const redis = c.get("redis");
130 +
	const db = c.get("db");
132 131
	const did = getSessionDid(c);
133 132
134 133
	if (!did) {
136 135
	}
137 136
138 137
	try {
139 -
		const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME);
138 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
140 139
		const session = await client.restore(did);
141 140
142 -
		const handle = await redis.get(`oauth_handle:${session.did}`);
141 +
		const handle = kvGet(db, `oauth_handle:${session.did}`);
143 142
144 143
		return c.json({
145 144
			authenticated: true,
packages/server/src/routes/subscribe.ts +8 −8
1 1
import { Agent } from "@atproto/api";
2 2
import { Hono } from "hono";
3 -
import type { RedisClient } from "bun";
3 +
import type { Database } from "bun:sqlite";
4 4
import { createOAuthClient } from "../lib/oauth-client";
5 5
import { getSessionDid, setReturnToCookie } from "../lib/session";
6 6
import { page, escapeHtml } from "../lib/theme";
7 7
import type { Env } from "../env";
8 8
9 -
type Variables = { env: Env; redis: RedisClient };
9 +
type Variables = { env: Env; db: Database };
10 10
11 11
const subscribe = new Hono<{ Variables: Variables }>();
12 12
66 66
67 67
subscribe.post("/", async (c) => {
68 68
	const env = c.get("env");
69 -
	const redis = c.get("redis");
69 +
	const db = c.get("db");
70 70
71 71
	let publicationUri: string;
72 72
	try {
87 87
	}
88 88
89 89
	try {
90 -
		const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME);
90 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
91 91
		const session = await client.restore(did);
92 92
		const agent = new Agent(session);
93 93
131 131
132 132
subscribe.get("/", async (c) => {
133 133
	const env = c.get("env");
134 -
	const redis = c.get("redis");
134 +
	const db = c.get("db");
135 135
136 136
	const publicationUri = c.req.query("publicationUri");
137 137
	const action = c.req.query("action");
157 157
	}
158 158
159 159
	try {
160 -
		const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME);
160 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
161 161
		const session = await client.restore(did);
162 162
		const agent = new Agent(session);
163 163
256 256
257 257
subscribe.get("/check", async (c) => {
258 258
	const env = c.get("env");
259 -
	const redis = c.get("redis");
259 +
	const db = c.get("db");
260 260
261 261
	const publicationUri = c.req.query("publicationUri");
262 262
270 270
	}
271 271
272 272
	try {
273 -
		const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME);
273 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
274 274
		const session = await client.restore(did);
275 275
		const agent = new Agent(session);
276 276
		const recordUri = await findExistingSubscription(