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

This commit is contained in:
Ryan Vogel
2026-02-12 13:08:10 -05:00
parent 9444c95eb2
commit bbab5b10d3
9 changed files with 305 additions and 194 deletions

View File

@@ -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

View File

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

View File

@@ -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.

View File

@@ -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.

View File

@@ -14,7 +14,6 @@
},
"dependencies": {
"discord.js": "^14",
"@neondatabase/serverless": "^0.10",
"@daytonaio/sdk": "latest",
"@opencode-ai/sdk": "latest",
"effect": "^3",

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)`)
}
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)
})
}

View File

@@ -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<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[]>;
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<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 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
}