From bbab5b10d3d93b68ee42244215b811f43fb9971c Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Thu, 12 Feb 2026 13:08:10 -0500 Subject: [PATCH] core: replace Neon Postgres with bun:sqlite to eliminate external DB signup --- packages/discord/.env.example | 2 +- packages/discord/.gitignore | 5 + packages/discord/AGENTS.md | 46 ++++- packages/discord/README.md | 20 +- packages/discord/package.json | 1 - packages/discord/src/config.ts | 51 ++--- packages/discord/src/db/client.ts | 16 +- packages/discord/src/db/init.ts | 89 ++++---- packages/discord/src/sessions/store.ts | 269 +++++++++++++++---------- 9 files changed, 305 insertions(+), 194 deletions(-) diff --git a/packages/discord/.env.example b/packages/discord/.env.example index 4753c32590..0839d81ca2 100644 --- a/packages/discord/.env.example +++ b/packages/discord/.env.example @@ -1,6 +1,6 @@ # Discord DISCORD_TOKEN= -DATABASE_URL= # Neon Postgres connection string +DATABASE_PATH=discord.sqlite # SQLite file path ALLOWED_CHANNEL_IDS= # Comma-separated Discord channel IDs DISCORD_ROLE_ID= # Role ID that triggers the bot (optional, for @role mentions) DISCORD_CATEGORY_ID= # Optional category ID that is allowed diff --git a/packages/discord/.gitignore b/packages/discord/.gitignore index f7ab915e48..a1970c9c1e 100644 --- a/packages/discord/.gitignore +++ b/packages/discord/.gitignore @@ -29,5 +29,10 @@ Thumbs.db # Logs *.log +# Local SQLite +*.sqlite +*.sqlite-shm +*.sqlite-wal + # Sensitive .censitive diff --git a/packages/discord/AGENTS.md b/packages/discord/AGENTS.md index 55e74909ae..db9ad8b394 100644 --- a/packages/discord/AGENTS.md +++ b/packages/discord/AGENTS.md @@ -4,74 +4,90 @@ Guide for coding agents working in this repository. Use this file for build/test commands and coding conventions. ## Project Snapshot + - Stack: Bun + TypeScript (ESM, strict mode) - App: Discord bot that provisions Daytona sandboxes -- Persistence: Neon Postgres (`discord_sessions`) +- Persistence: SQLite (`discord.sqlite`, table `discord_sessions`) - Runtime flow: Discord thread -> sandbox -> OpenCode session - Ops: structured JSON logs + `/healthz` and `/readyz` ## Repository Map + - `src/index.ts`: startup, wiring, graceful shutdown - `src/config.ts`: env schema and parsing (Zod) - `src/discord/`: Discord client + handlers + routing logic - `src/sandbox/`: sandbox lifecycle + OpenCode transport -- `src/sessions/store.ts`: Neon-backed session store +- `src/sessions/store.ts`: SQLite-backed session store - `src/db/init.ts`: idempotent DB schema initialization - `src/http/health.ts`: health/readiness HTTP server - `.env.example`: env contract ## Setup and Run Commands + ### Install + - `bun install` ### First-time local setup + - `cp .env.example .env` - Fill required secrets in `.env` - Initialize schema: `bun run db:init` ### Development run + - Watch mode: `bun run dev` - Normal run: `bun run start` - Dev bootstrap helper: `bun run dev:setup` ### Static checks + - Typecheck: `bun run typecheck` - Build: `bun run build` - Combined check: `bun run check` ### Health checks + - `curl -s http://127.0.0.1:8787/healthz` - `curl -i http://127.0.0.1:8787/readyz` ## Testing Commands + There is no first-party test suite in `src/` yet. Use Bun test commands for new tests. + - Run all tests (if present): `bun test` - Run a single test file: `bun test path/to/file.test.ts` - Run one file in watch mode: `bun test --watch path/to/file.test.ts` -When adding tests, prefer colocated `*.test.ts` near implementation files. + When adding tests, prefer colocated `*.test.ts` near implementation files. ## Cursor / Copilot Rules + Checked these paths: + - `.cursor/rules/` - `.cursorrules` - `.github/copilot-instructions.md` -No Cursor/Copilot rule files currently exist in this repo. -If added later, update this file and follow those rules. + No Cursor/Copilot rule files currently exist in this repo. + If added later, update this file and follow those rules. ## Code Style + ### TypeScript and modules + - Keep code strict-TypeScript compatible. - Use ESM imports/exports only. - Prefer named exports over default exports. - Add explicit return types on exported functions. ### Imports + - Group imports as: external first, then internal. - Use `import type` for type-only imports. - Keep import paths consistent with existing relative style. ### Formatting + - Match existing style: - double quotes - semicolons @@ -80,18 +96,21 @@ If added later, update this file and follow those rules. - Avoid comments unless logic is non-obvious. ### Naming + - `camelCase`: variables/functions - `PascalCase`: classes/interfaces/type aliases - `UPPER_SNAKE_CASE`: env keys and constants - Log events should be stable (`domain.action.result`). ### Types and contracts + - Reuse shared types from `src/types.ts`. - Preserve `SessionStatus` semantics when adding new states. - Prefer `unknown` over `any` at untrusted boundaries. - Narrow and validate external data before use. ## Error Handling and Logging + - Use `logger` from `src/observability/logger.ts`. - Do not add raw `console.log` in app paths. - Include context fields when available: @@ -106,29 +125,35 @@ If added later, update this file and follow those rules. - Never log secret values. ## Environment and Secrets + - Read env only through `getEnv()`. - Update `.env.example` for env schema changes. - Keep auth tokens out of command strings and logs. - Pass runtime secrets via environment variables. ## Domain-Specific Rules + ### Session lifecycle -- Neon mapping (`thread_id`, `sandbox_id`, `session_id`) is authoritative. + +- Session mapping (`thread_id`, `sandbox_id`, `session_id`) is authoritative. - Resume existing sandbox/session before creating replacements. - Recreate only when sandbox is unavailable/destroyed. - If session changes, replay Discord thread history as fallback context. ### Daytona behavior + - `stop()` clears running processes but keeps filesystem state. - `start()` requires process bootstrap (`opencode serve`). - Keep lifecycle transitions deterministic and observable. ### OpenCode transport + - Keep preview token separate from persisted URL when possible. - Send token using `x-daytona-preview-token` header. - Keep retry loops bounded and configurable. ### Discord handler behavior + - Ignore bot/self chatter and respect mention/role gating. - Preserve thread ownership checks for bot-managed threads. - Keep outbound messages chunked for Discord size limits. @@ -136,41 +161,50 @@ If added later, update this file and follow those rules. ## Non-Obvious Discoveries ### OpenCode session persistence + - Sessions are disk-persistent JSON files in `~/.local/share/opencode/storage/session//` - Sessions survive `opencode serve` restarts if filesystem intact AND process restarts from same git repo directory - Sessions are scoped by `projectID` = git root commit hash (or `"global"` for non-git dirs) - After `daytona.start()`, processes are guaranteed dead - always restart `opencode serve` immediately, don't wait for health first (`src/sandbox/manager.ts:400-420`) ### Session reattach debugging + - If `sessionExists()` returns false but sandbox filesystem is intact, search by title (`Discord thread `) via `listSessions()` - session may exist under different ID due to OpenCode internal state changes - Thread lock per `threadId` prevents concurrent create/resume races (`src/sandbox/manager.ts:614-632`) - Never fall back to new sandbox when `daytona.start()` succeeds - filesystem is intact, only OpenCode process needs restart ### Discord + multiple processes + - Multiple bot processes with same `DISCORD_TOKEN` cause race conditions - one succeeds, others fail with `DiscordAPIError[160004]` (thread already created) - PTY sessions with `exec bash -l` stay alive after command exits, leading to duplicate bot runtimes if not cleaned up ### Sandbox runtime auth + - Pass `GITHUB_TOKEN` as process env to `opencode serve` via `sandbox.process.executeCommand()` `env` parameter - Never interpolate tokens into command strings - use `env` parameter in `exec()` options (`src/sandbox/manager.ts:27-72`) ## Agent Workflow Checklist + ### Before coding + - Read related modules and follow existing patterns. - Prefer narrow, minimal changes over broad refactors. ### During coding + - Keep behavior backwards-compatible unless intentionally changing it. - Keep changes cohesive (schema + store + manager together). - Add/update logs for important lifecycle branches. ### After coding + - Run `bun run typecheck` - Run `bun run build` - Run `bun run db:init` for schema-affecting changes - Smoke-check health endpoints if bootstrap/runtime changed ## Git/PR Safety for Agents + - Do not commit or push unless explicitly requested. - Do not amend commits unless explicitly requested. - Avoid destructive git commands unless explicitly requested. diff --git a/packages/discord/README.md b/packages/discord/README.md index c8ef92d809..4d65a42c9c 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -22,21 +22,21 @@ bun run dev ## Commands -| Command | Description | -|---------|-------------| -| `bun run dev` | Watch mode | -| `bun run start` | Production run | -| `bun run db:init` | Initialize/migrate database | -| `bun run typecheck` | TypeScript checks | -| `bun run build` | Bundle for deployment | -| `bun run check` | Typecheck + build | +| Command | Description | +| ------------------- | --------------------------- | +| `bun run dev` | Watch mode | +| `bun run start` | Production run | +| `bun run db:init` | Initialize/migrate database | +| `bun run typecheck` | TypeScript checks | +| `bun run build` | Bundle for deployment | +| `bun run check` | Typecheck + build | ## Configuration See [`.env.example`](.env.example) for all available environment variables. Required: - `DISCORD_TOKEN` — Discord bot token -- `DATABASE_URL` — Neon Postgres connection string +- `DATABASE_PATH` — SQLite file path (default: `discord.sqlite`) - `DAYTONA_API_KEY` — Daytona API key - `OPENCODE_ZEN_API_KEY` — OpenCode API key @@ -56,4 +56,4 @@ Discord thread └─ missing? → create sandbox → clone repo → start opencode → new session ``` -Sessions are persisted in Neon Postgres. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start. +Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start. diff --git a/packages/discord/package.json b/packages/discord/package.json index 336ba91046..90d711d57b 100644 --- a/packages/discord/package.json +++ b/packages/discord/package.json @@ -14,7 +14,6 @@ }, "dependencies": { "discord.js": "^14", - "@neondatabase/serverless": "^0.10", "@daytonaio/sdk": "latest", "@opencode-ai/sdk": "latest", "effect": "^3", diff --git a/packages/discord/src/config.ts b/packages/discord/src/config.ts index dba9d7018f..4bd6ebc0b0 100644 --- a/packages/discord/src/config.ts +++ b/packages/discord/src/config.ts @@ -1,17 +1,20 @@ -import { z } from "zod"; +import { z } from "zod" const envSchema = z.object({ DISCORD_TOKEN: z.string().min(1), - ALLOWED_CHANNEL_IDS: z.string().default("").transform((s) => - s - .split(",") - .map((id) => id.trim()) - .filter((id) => id.length > 0), - ), + ALLOWED_CHANNEL_IDS: z + .string() + .default("") + .transform((s) => + s + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0), + ), DISCORD_CATEGORY_ID: z.string().default(""), DISCORD_ROLE_ID: z.string().default(""), DISCORD_REQUIRED_ROLE_ID: z.string().default(""), - DATABASE_URL: z.string().min(1), + DATABASE_PATH: z.string().default("discord.sqlite"), DAYTONA_API_KEY: z.string().min(1), OPENCODE_ZEN_API_KEY: z.string().min(1), GITHUB_TOKEN: z.string().default(""), @@ -30,27 +33,29 @@ const envSchema = z.object({ RESUME_HEALTH_TIMEOUT_MS: z.coerce.number().default(120000), SANDBOX_CREATION_TIMEOUT: z.coerce.number().default(180), OPENCODE_MODEL: z.string().default("opencode/claude-sonnet-4-5"), -}); +}) -export type Env = z.infer; +export type Env = z.infer -let _config: Env | null = null; +let _config: Env | null = null export function getEnv(): Env { if (!_config) { - const result = envSchema.safeParse(process.env); + const result = envSchema.safeParse(process.env) if (!result.success) { - console.error(JSON.stringify({ - ts: new Date().toISOString(), - level: "error", - event: "config.invalid", - component: "config", - message: "Invalid environment variables", - fieldErrors: result.error.flatten().fieldErrors, - })); - throw new Error("Invalid environment configuration"); + console.error( + JSON.stringify({ + ts: new Date().toISOString(), + level: "error", + event: "config.invalid", + component: "config", + message: "Invalid environment variables", + fieldErrors: result.error.flatten().fieldErrors, + }), + ) + throw new Error("Invalid environment configuration") } - _config = result.data; + _config = result.data } - return _config; + return _config } diff --git a/packages/discord/src/db/client.ts b/packages/discord/src/db/client.ts index fdefce7294..d350dadf6f 100644 --- a/packages/discord/src/db/client.ts +++ b/packages/discord/src/db/client.ts @@ -1,11 +1,13 @@ -import { neon } from "@neondatabase/serverless"; -import { getEnv } from "../config"; +import { Database } from "bun:sqlite" +import { getEnv } from "../config" -let _sql: ReturnType | null = null; +let _db: Database | null = null -export function getSql() { - if (!_sql) { - _sql = neon(getEnv().DATABASE_URL); +export function getDb(): Database { + if (!_db) { + _db = new Database(getEnv().DATABASE_PATH, { create: true }) + _db.exec("PRAGMA journal_mode = WAL;") + _db.exec("PRAGMA busy_timeout = 5000;") } - return _sql; + return _db } diff --git a/packages/discord/src/db/init.ts b/packages/discord/src/db/init.ts index a255297765..22fbfba3ad 100644 --- a/packages/discord/src/db/init.ts +++ b/packages/discord/src/db/init.ts @@ -1,12 +1,13 @@ -import { getSql } from "./client"; -import { logger } from "../observability/logger"; +import type { Database } from "bun:sqlite" +import { getDb } from "./client" +import { logger } from "../observability/logger" -const PREFIX = "[db]"; +const PREFIX = "[db]" export async function initializeDatabase(): Promise { - const sql = getSql(); + const db = getDb() - await sql`CREATE TABLE IF NOT EXISTS discord_sessions ( + db.exec(`CREATE TABLE IF NOT EXISTS discord_sessions ( thread_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, guild_id TEXT NOT NULL, @@ -15,51 +16,61 @@ export async function initializeDatabase(): Promise { preview_url TEXT NOT NULL, preview_token TEXT, status TEXT NOT NULL CHECK (status IN ('creating', 'active', 'pausing', 'paused', 'resuming', 'destroying', 'destroyed', 'error')), - last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(), - pause_requested_at TIMESTAMPTZ, - paused_at TIMESTAMPTZ, - resume_attempted_at TIMESTAMPTZ, - resumed_at TIMESTAMPTZ, - destroyed_at TIMESTAMPTZ, - last_health_ok_at TIMESTAMPTZ, + last_activity TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + pause_requested_at TEXT, + paused_at TEXT, + resume_attempted_at TEXT, + resumed_at TEXT, + destroyed_at TEXT, + last_health_ok_at TEXT, last_error TEXT, resume_fail_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - )`; + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`) - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS preview_token TEXT`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW()`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS pause_requested_at TIMESTAMPTZ`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS resume_attempted_at TIMESTAMPTZ`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS resumed_at TIMESTAMPTZ`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS destroyed_at TIMESTAMPTZ`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS last_health_ok_at TIMESTAMPTZ`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS last_error TEXT`; - await sql`ALTER TABLE discord_sessions ADD COLUMN IF NOT EXISTS resume_fail_count INTEGER NOT NULL DEFAULT 0`; + addColumn(db, "preview_token", "TEXT") + addColumn(db, "last_activity", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP") + addColumn(db, "created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP") + addColumn(db, "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP") + addColumn(db, "pause_requested_at", "TEXT") + addColumn(db, "paused_at", "TEXT") + addColumn(db, "resume_attempted_at", "TEXT") + addColumn(db, "resumed_at", "TEXT") + addColumn(db, "destroyed_at", "TEXT") + addColumn(db, "last_health_ok_at", "TEXT") + addColumn(db, "last_error", "TEXT") + addColumn(db, "resume_fail_count", "INTEGER NOT NULL DEFAULT 0") - await sql`ALTER TABLE discord_sessions DROP CONSTRAINT IF EXISTS discord_sessions_status_check`; - await sql`ALTER TABLE discord_sessions - ADD CONSTRAINT discord_sessions_status_check - CHECK (status IN ('creating', 'active', 'pausing', 'paused', 'resuming', 'destroying', 'destroyed', 'error'))`; + db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_last_activity_idx + ON discord_sessions (status, last_activity)`) - await sql`CREATE INDEX IF NOT EXISTS discord_sessions_status_last_activity_idx - ON discord_sessions (status, last_activity)`; + db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_updated_at_idx + ON discord_sessions (status, updated_at)`) +} - await sql`CREATE INDEX IF NOT EXISTS discord_sessions_status_updated_at_idx - ON discord_sessions (status, updated_at)`; +function addColumn(db: Database, name: string, definition: string): void { + if (hasColumn(db, name)) return + db.exec(`ALTER TABLE discord_sessions ADD COLUMN ${name} ${definition}`) +} + +function hasColumn(db: Database, name: string): boolean { + const rows = db.query("PRAGMA table_info(discord_sessions)").all() as Array<{ name: string }> + return rows.some((row) => row.name === name) } if (import.meta.main) { initializeDatabase() .then(() => { - logger.info({ event: "db.schema.ready", component: "db", message: `${PREFIX} Schema is ready` }); + logger.info({ event: "db.schema.ready", component: "db", message: `${PREFIX} Schema is ready` }) }) .catch((err) => { - logger.error({ event: "db.schema.failed", component: "db", message: `${PREFIX} Failed to initialize schema`, error: err }); - process.exit(1); - }); + logger.error({ + event: "db.schema.failed", + component: "db", + message: `${PREFIX} Failed to initialize schema`, + error: err, + }) + process.exit(1) + }) } diff --git a/packages/discord/src/sessions/store.ts b/packages/discord/src/sessions/store.ts index 2cea38bb20..d43dd39690 100644 --- a/packages/discord/src/sessions/store.ts +++ b/packages/discord/src/sessions/store.ts @@ -1,38 +1,40 @@ -import { getSql } from "../db/client"; -import type { SessionInfo, SessionStatus } from "../types"; +import { getDb } from "../db/client" +import type { SessionInfo, SessionStatus } from "../types" type SessionRow = { - thread_id: string; - channel_id: string; - guild_id: string; - sandbox_id: string; - session_id: string; - preview_url: string; - preview_token: string | null; - status: SessionStatus; - last_error: string | null; - resume_fail_count: number; -}; - -export interface SessionStore { - upsert(session: SessionInfo): Promise; - getByThread(threadId: string): Promise; - hasTrackedThread(threadId: string): Promise; - getActive(threadId: string): Promise; - markActivity(threadId: string): Promise; - markHealthOk(threadId: string): Promise; - updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise; - incrementResumeFailure(threadId: string, lastError: string): Promise; - listActive(): Promise; - listStaleActive(cutoffMinutes: number): Promise; - listExpiredPaused(pausedTtlMinutes: number): Promise; + thread_id: string + channel_id: string + guild_id: string + sandbox_id: string + session_id: string + preview_url: string + preview_token: string | null + status: SessionStatus + last_error: string | null + resume_fail_count: number } -class NeonSessionStore implements SessionStore { - private readonly sql = getSql(); +export interface SessionStore { + upsert(session: SessionInfo): Promise + getByThread(threadId: string): Promise + hasTrackedThread(threadId: string): Promise + getActive(threadId: string): Promise + markActivity(threadId: string): Promise + markHealthOk(threadId: string): Promise + updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise + incrementResumeFailure(threadId: string, lastError: string): Promise + listActive(): Promise + listStaleActive(cutoffMinutes: number): Promise + listExpiredPaused(pausedTtlMinutes: number): Promise +} + +class SqliteSessionStore implements SessionStore { + private readonly db = getDb() async upsert(session: SessionInfo): Promise { - await this.sql` + this.db + .query( + ` INSERT INTO discord_sessions ( thread_id, channel_id, @@ -48,38 +50,53 @@ class NeonSessionStore implements SessionStore { created_at, updated_at ) VALUES ( - ${session.threadId}, - ${session.channelId}, - ${session.guildId}, - ${session.sandboxId}, - ${session.sessionId}, - ${session.previewUrl}, - ${session.previewToken}, - ${session.status}, - ${session.lastError ?? null}, - NOW(), - CASE WHEN ${session.status} = 'active' THEN NOW() ELSE NULL END, - NOW(), - NOW() + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + CURRENT_TIMESTAMP, + CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE NULL END, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP ) - ON CONFLICT (thread_id) + ON CONFLICT(thread_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - guild_id = EXCLUDED.guild_id, - sandbox_id = EXCLUDED.sandbox_id, - session_id = EXCLUDED.session_id, - preview_url = EXCLUDED.preview_url, - preview_token = EXCLUDED.preview_token, - status = EXCLUDED.status, - last_error = EXCLUDED.last_error, - last_activity = NOW(), - resumed_at = CASE WHEN EXCLUDED.status = 'active' THEN NOW() ELSE discord_sessions.resumed_at END, - updated_at = NOW() - `; + channel_id = excluded.channel_id, + guild_id = excluded.guild_id, + sandbox_id = excluded.sandbox_id, + session_id = excluded.session_id, + preview_url = excluded.preview_url, + preview_token = excluded.preview_token, + status = excluded.status, + last_error = excluded.last_error, + last_activity = CURRENT_TIMESTAMP, + resumed_at = CASE WHEN excluded.status = 'active' THEN CURRENT_TIMESTAMP ELSE discord_sessions.resumed_at END, + updated_at = CURRENT_TIMESTAMP + `, + ) + .run( + session.threadId, + session.channelId, + session.guildId, + session.sandboxId, + session.sessionId, + session.previewUrl, + session.previewToken, + session.status, + session.lastError ?? null, + session.status, + ) } async getByThread(threadId: string): Promise { - const rows = await this.sql` + const row = this.db + .query( + ` SELECT thread_id, channel_id, @@ -92,27 +109,35 @@ class NeonSessionStore implements SessionStore { last_error, resume_fail_count FROM discord_sessions - WHERE thread_id = ${threadId} + WHERE thread_id = ? LIMIT 1 - ` as SessionRow[]; + `, + ) + .get(threadId) as SessionRow | null - if (rows.length === 0) return null; - return toSessionInfo(rows[0]); + if (!row) return null + return toSessionInfo(row) } async hasTrackedThread(threadId: string): Promise { - const rows = await this.sql` + const row = this.db + .query( + ` SELECT thread_id FROM discord_sessions - WHERE thread_id = ${threadId} + WHERE thread_id = ? LIMIT 1 - ` as Array<{ thread_id: string }>; + `, + ) + .get(threadId) as { thread_id: string } | null - return rows.length > 0; + return Boolean(row) } async getActive(threadId: string): Promise { - const rows = await this.sql` + const row = this.db + .query( + ` SELECT thread_id, channel_id, @@ -125,60 +150,80 @@ class NeonSessionStore implements SessionStore { last_error, resume_fail_count FROM discord_sessions - WHERE thread_id = ${threadId} + WHERE thread_id = ? AND status = 'active' LIMIT 1 - ` as SessionRow[]; + `, + ) + .get(threadId) as SessionRow | null - if (rows.length === 0) return null; - return toSessionInfo(rows[0]); + if (!row) return null + return toSessionInfo(row) } async markActivity(threadId: string): Promise { - await this.sql` + this.db + .query( + ` UPDATE discord_sessions - SET last_activity = NOW(), updated_at = NOW() - WHERE thread_id = ${threadId} - `; + SET last_activity = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE thread_id = ? + `, + ) + .run(threadId) } async markHealthOk(threadId: string): Promise { - await this.sql` + this.db + .query( + ` UPDATE discord_sessions - SET last_health_ok_at = NOW(), updated_at = NOW() - WHERE thread_id = ${threadId} - `; + SET last_health_ok_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE thread_id = ? + `, + ) + .run(threadId) } async updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise { - await this.sql` + this.db + .query( + ` UPDATE discord_sessions SET - status = ${status}, - last_error = ${lastError ?? null}, - pause_requested_at = CASE WHEN ${status} = 'pausing' THEN NOW() ELSE pause_requested_at END, - paused_at = CASE WHEN ${status} = 'paused' THEN NOW() ELSE paused_at END, - resume_attempted_at = CASE WHEN ${status} = 'resuming' THEN NOW() ELSE resume_attempted_at END, - resumed_at = CASE WHEN ${status} = 'active' THEN NOW() ELSE resumed_at END, - destroyed_at = CASE WHEN ${status} = 'destroyed' THEN NOW() ELSE destroyed_at END, - updated_at = NOW() - WHERE thread_id = ${threadId} - `; + status = ?, + last_error = ?, + pause_requested_at = CASE WHEN ? = 'pausing' THEN CURRENT_TIMESTAMP ELSE pause_requested_at END, + paused_at = CASE WHEN ? = 'paused' THEN CURRENT_TIMESTAMP ELSE paused_at END, + resume_attempted_at = CASE WHEN ? = 'resuming' THEN CURRENT_TIMESTAMP ELSE resume_attempted_at END, + resumed_at = CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE resumed_at END, + destroyed_at = CASE WHEN ? = 'destroyed' THEN CURRENT_TIMESTAMP ELSE destroyed_at END, + updated_at = CURRENT_TIMESTAMP + WHERE thread_id = ? + `, + ) + .run(status, lastError ?? null, status, status, status, status, status, threadId) } async incrementResumeFailure(threadId: string, lastError: string): Promise { - await this.sql` + this.db + .query( + ` UPDATE discord_sessions SET resume_fail_count = resume_fail_count + 1, - last_error = ${lastError}, - updated_at = NOW() - WHERE thread_id = ${threadId} - `; + last_error = ?, + updated_at = CURRENT_TIMESTAMP + WHERE thread_id = ? + `, + ) + .run(lastError, threadId) } async listActive(): Promise { - const rows = await this.sql` + const rows = this.db + .query( + ` SELECT thread_id, channel_id, @@ -193,13 +238,17 @@ class NeonSessionStore implements SessionStore { FROM discord_sessions WHERE status = 'active' ORDER BY last_activity DESC - ` as SessionRow[]; + `, + ) + .all() as SessionRow[] - return rows.map(toSessionInfo); + return rows.map(toSessionInfo) } async listStaleActive(cutoffMinutes: number): Promise { - const rows = await this.sql` + const rows = this.db + .query( + ` SELECT thread_id, channel_id, @@ -213,15 +262,19 @@ class NeonSessionStore implements SessionStore { resume_fail_count FROM discord_sessions WHERE status = 'active' - AND last_activity < NOW() - (${cutoffMinutes} || ' minutes')::interval + AND last_activity < datetime('now', '-' || ? || ' minutes') ORDER BY last_activity ASC - ` as SessionRow[]; + `, + ) + .all(cutoffMinutes) as SessionRow[] - return rows.map(toSessionInfo); + return rows.map(toSessionInfo) } async listExpiredPaused(pausedTtlMinutes: number): Promise { - const rows = await this.sql` + const rows = this.db + .query( + ` SELECT thread_id, channel_id, @@ -236,11 +289,13 @@ class NeonSessionStore implements SessionStore { FROM discord_sessions WHERE status = 'paused' AND paused_at IS NOT NULL - AND paused_at < NOW() - (${pausedTtlMinutes} || ' minutes')::interval + AND paused_at < datetime('now', '-' || ? || ' minutes') ORDER BY paused_at ASC - ` as SessionRow[]; + `, + ) + .all(pausedTtlMinutes) as SessionRow[] - return rows.map(toSessionInfo); + return rows.map(toSessionInfo) } } @@ -256,11 +311,11 @@ function toSessionInfo(row: SessionRow): SessionInfo { status: row.status, lastError: row.last_error, resumeFailCount: row.resume_fail_count, - }; + } } -const sessionStore: SessionStore = new NeonSessionStore(); +const sessionStore: SessionStore = new SqliteSessionStore() export function getSessionStore(): SessionStore { - return sessionStore; + return sessionStore }