Просмотр исходного кода

core: replace Neon Postgres with bun:sqlite to eliminate external DB signup

Ryan Vogel 2 месяцев назад
Родитель
Сommit
bbab5b10d3

+ 1 - 1
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

+ 5 - 0
packages/discord/.gitignore

@@ -29,5 +29,10 @@ Thumbs.db
 # Logs
 *.log
 
+# Local SQLite
+*.sqlite
+*.sqlite-shm
+*.sqlite-wal
+
 # Sensitive
 .censitive

+ 40 - 6
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/<projectID>/`
 - 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 <threadId>`) 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.

+ 10 - 10
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.

+ 0 - 1
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",

+ 28 - 23
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<typeof envSchema>;
+export type Env = z.infer<typeof envSchema>
 
-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
 }

+ 9 - 7
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<typeof neon> | 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
 }

+ 50 - 39
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<void> {
-  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<void> {
     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)`)
+}
+
+function addColumn(db: Database, name: string, definition: string): void {
+  if (hasColumn(db, name)) return
+  db.exec(`ALTER TABLE discord_sessions ADD COLUMN ${name} ${definition}`)
+}
 
-  await sql`CREATE INDEX IF NOT EXISTS discord_sessions_status_updated_at_idx
-    ON discord_sessions (status, updated_at)`;
+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)
+    })
 }

+ 160 - 105
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;
-};
+  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<void>;
-  getByThread(threadId: string): Promise<SessionInfo | null>;
-  hasTrackedThread(threadId: string): Promise<boolean>;
-  getActive(threadId: string): Promise<SessionInfo | null>;
-  markActivity(threadId: string): Promise<void>;
-  markHealthOk(threadId: string): Promise<void>;
-  updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise<void>;
-  incrementResumeFailure(threadId: string, lastError: string): Promise<void>;
-  listActive(): Promise<SessionInfo[]>;
-  listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]>;
-  listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]>;
+  upsert(session: SessionInfo): Promise<void>
+  getByThread(threadId: string): Promise<SessionInfo | null>
+  hasTrackedThread(threadId: string): Promise<boolean>
+  getActive(threadId: string): Promise<SessionInfo | null>
+  markActivity(threadId: string): Promise<void>
+  markHealthOk(threadId: string): Promise<void>
+  updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise<void>
+  incrementResumeFailure(threadId: string, lastError: string): Promise<void>
+  listActive(): Promise<SessionInfo[]>
+  listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]>
+  listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]>
 }
 
-class NeonSessionStore implements SessionStore {
-  private readonly sql = getSql();
+class SqliteSessionStore implements SessionStore {
+  private readonly db = getDb()
 
   async upsert(session: SessionInfo): Promise<void> {
-    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<SessionInfo | null> {
-    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<boolean> {
-    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<SessionInfo | null> {
-    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<void> {
-    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<void> {
-    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<void> {
-    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<void> {
-    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<SessionInfo[]> {
-    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<SessionInfo[]> {
-    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<SessionInfo[]> {
-    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
 }