Skip to content

Commit 7cd6694

Browse files
committed
upgrade to postgresql
1 parent b8ed9e8 commit 7cd6694

20 files changed

+873
-912
lines changed

Cargo.lock

Lines changed: 312 additions & 158 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,15 @@ dotenv = { version = "0.15.0", features = ["clap"] }
4040
color-eyre = "0.6.3"
4141
vesper = "0.13.0"
4242
once_cell = { version = "1.19.0", features = ["parking_lot"] }
43-
serde_rusqlite = "0.39.0"
4443
openrouter_api = { git = "https://github.com/socrates8300/openrouter_api.git", rev = "96f8bd7f5ec219abb2335ac16110835e15487c66" }
4544
thiserror = "2.0.12"
46-
r2d2_sqlite = "0.30.0"
47-
r2d2 = "0.8.10"
48-
wb_sqlite = "0.2.1"
49-
rusqlite = { version = "0.36.0", features = ["bundled"] }
5045
num-format = "0.4.4"
5146
fasteval = "0.2.4"
5247
png = "0.17"
5348
axum = "0.8"
5449
tera = "1"
5550
tempfile = "3"
5651
urlencoding = "2.1"
52+
tokio-postgres = "0.7.16"
53+
deadpool-postgres = { version = "0.14.1", features = ["rt_tokio_1"] }
54+
postgres-from-row = "0.5.2"

docs/MIGRATION.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# SQLite → PostgreSQL Migration
2+
3+
## Overview
4+
5+
The bot uses PostgreSQL with `tokio-postgres` + `deadpool-postgres`. On every startup
6+
`run_migrations` runs both SQL files in order — idempotent, so safe to run repeatedly.
7+
8+
```
9+
migrations/
10+
001_init.sql — base schema (mirrors SQLite layout, used for fresh installs)
11+
002_improve_types.sql — upgrades to PG-native types (runs after pgloader or after 001)
12+
pgloader.load — pgloader script for one-time SQLite data migration
13+
```
14+
15+
---
16+
17+
## Fresh install (no existing data)
18+
19+
1. Create a PostgreSQL database.
20+
2. Set `DATABASE_URL` and start the bot — migrations run automatically on startup.
21+
22+
```bash
23+
DATABASE_URL=postgres://user:pass@localhost/trickedbot cargo run
24+
```
25+
26+
---
27+
28+
## Migrating from existing SQLite (`database.db`)
29+
30+
### 1. Install pgloader
31+
32+
```bash
33+
nix-shell -p pgloader # or: apt install pgloader
34+
```
35+
36+
### 2. Edit `migrations/pgloader.load`
37+
38+
Fill in the real paths:
39+
40+
```
41+
FROM sqlite:///absolute/path/to/database.db
42+
INTO postgresql://user:pass@localhost/trickedbot
43+
```
44+
45+
### 3. Run pgloader
46+
47+
```bash
48+
pgloader migrations/pgloader.load
49+
```
50+
51+
pgloader creates the tables and loads all data. The CAST rules handle:
52+
- `user.id` — TEXT in SQLite (rusqlite stored u64 as strings) → BIGINT
53+
- `memory.user_id` — stored as integer in SQLite → BIGINT
54+
- all other integer columns → BIGINT (002 downcasts level/xp back to INT)
55+
56+
### 4. Start the bot
57+
58+
```bash
59+
DATABASE_URL=postgres://user:pass@localhost/trickedbot cargo run
60+
```
61+
62+
On startup `run_migrations` runs:
63+
- **001** — no-op (tables already exist from pgloader)
64+
- **002** — upgrades types in-place:
65+
- `user.id` TEXT → BIGINT (if not already done by pgloader CAST)
66+
- `user.level`, `user.xp` BIGINT → INT (pgloader over-widens all integers)
67+
- `memory.user_id` TEXT → BIGINT (if not already done)
68+
- `math_question.answer` real → double precision
69+
- adds FK `memory.user_id → user.id ON DELETE CASCADE` (drops orphaned memories first)
70+
71+
All 002 steps check current column types before acting — safe to re-run.
72+
73+
---
74+
75+
## Notes
76+
77+
- `math_question` — pgloader snake_cases the SQLite table name `MathQuestion`; the bot queries `math_question` accordingly.
78+
- Orphaned memories (referencing users who left) are automatically deleted before the FK is added.
79+
- `DATABASE_FILE` / `--database-file` no longer exists; use `DATABASE_URL` / `--database-url`.

migrations/001_init.sql

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- Initial schema matching the original SQLite structure.
2+
-- Keeps memory.user_id as TEXT to match SQLite's original layout.
3+
-- Run 002_improve_types.sql after loading to upgrade to native PG types.
4+
5+
CREATE TABLE IF NOT EXISTS "user" (
6+
id BIGINT PRIMARY KEY,
7+
level INT NOT NULL DEFAULT 0,
8+
xp INT NOT NULL DEFAULT 0,
9+
social_credit BIGINT NOT NULL DEFAULT 0,
10+
name TEXT NOT NULL DEFAULT '',
11+
relationship TEXT NOT NULL DEFAULT '',
12+
example_input TEXT NOT NULL DEFAULT '',
13+
example_output TEXT NOT NULL DEFAULT ''
14+
);
15+
16+
CREATE TABLE IF NOT EXISTS memory (
17+
id BIGSERIAL PRIMARY KEY,
18+
user_id TEXT NOT NULL,
19+
content TEXT NOT NULL,
20+
key TEXT NOT NULL,
21+
UNIQUE (user_id, key)
22+
);
23+
24+
CREATE TABLE IF NOT EXISTS mathquestion (
25+
id SERIAL PRIMARY KEY,
26+
question TEXT NOT NULL,
27+
answer DOUBLE PRECISION NOT NULL
28+
);

migrations/002_improve_types.sql

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
-- Upgrade schema from SQLite-compatible types to PostgreSQL-native types.
2+
-- Idempotent: safe to run on any state (fresh, post-pgloader, or already upgraded).
3+
4+
-- 1. Convert user.id from TEXT to BIGINT (it was stored as string by rusqlite)
5+
DO $$ DECLARE
6+
pk_name text;
7+
BEGIN
8+
IF (SELECT data_type FROM information_schema.columns
9+
WHERE table_name = 'user' AND column_name = 'id') = 'text' THEN
10+
11+
-- Dynamically find and drop the PK so we can change the column type
12+
SELECT c.conname INTO pk_name
13+
FROM pg_constraint c
14+
JOIN pg_class t ON c.conrelid = t.oid
15+
WHERE t.relname = 'user' AND c.contype = 'p';
16+
17+
IF pk_name IS NOT NULL THEN
18+
EXECUTE format('ALTER TABLE "user" DROP CONSTRAINT %I', pk_name);
19+
END IF;
20+
21+
ALTER TABLE "user" ALTER COLUMN id TYPE BIGINT USING id::BIGINT;
22+
ALTER TABLE "user" ADD PRIMARY KEY (id);
23+
END IF;
24+
END $$;
25+
26+
-- 2. Downcast level/xp from BIGINT (pgloader global cast) back to INT (Rust i32)
27+
DO $$ BEGIN
28+
IF (SELECT data_type FROM information_schema.columns
29+
WHERE table_name = 'user' AND column_name = 'level') = 'bigint' THEN
30+
ALTER TABLE "user" ALTER COLUMN level TYPE INT USING level::INT;
31+
END IF;
32+
END $$;
33+
34+
DO $$ BEGIN
35+
IF (SELECT data_type FROM information_schema.columns
36+
WHERE table_name = 'user' AND column_name = 'xp') = 'bigint' THEN
37+
ALTER TABLE "user" ALTER COLUMN xp TYPE INT USING xp::INT;
38+
END IF;
39+
END $$;
40+
41+
-- 3. Convert memory.user_id from TEXT to BIGINT (if not already done by pgloader CAST)
42+
DO $$ BEGIN
43+
IF (SELECT data_type FROM information_schema.columns
44+
WHERE table_name = 'memory' AND column_name = 'user_id') = 'text' THEN
45+
46+
ALTER TABLE memory DROP CONSTRAINT IF EXISTS memory_user_id_key;
47+
ALTER TABLE memory DROP CONSTRAINT IF EXISTS memory_user_key_unique;
48+
49+
ALTER TABLE memory ALTER COLUMN user_id TYPE BIGINT USING user_id::BIGINT;
50+
51+
ALTER TABLE memory ADD CONSTRAINT memory_user_key_unique UNIQUE (user_id, key);
52+
END IF;
53+
END $$;
54+
55+
-- 4. Fix math_question.answer from real (float4) to double precision (float8)
56+
DO $$ BEGIN
57+
IF (SELECT data_type FROM information_schema.columns
58+
WHERE table_name = 'math_question' AND column_name = 'answer') = 'real' THEN
59+
ALTER TABLE math_question ALTER COLUMN answer TYPE DOUBLE PRECISION USING answer::DOUBLE PRECISION;
60+
END IF;
61+
END $$;
62+
63+
-- 5. Add FK memory.user_id -> "user".id with CASCADE delete
64+
DO $$ BEGIN
65+
IF NOT EXISTS (
66+
SELECT 1 FROM pg_constraint c
67+
JOIN pg_class t ON c.conrelid = t.oid
68+
WHERE t.relname = 'memory' AND c.contype = 'f'
69+
) THEN
70+
-- Remove orphaned memories (users who left/were deleted) before adding FK
71+
DELETE FROM memory WHERE user_id NOT IN (SELECT id FROM "user");
72+
ALTER TABLE memory ADD CONSTRAINT memory_user_id_fkey
73+
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
74+
END IF;
75+
END $$;

migrations/pgloader.load

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
LOAD DATABASE
2+
FROM sqlite:///path/to/database.db
3+
INTO postgresql://user:pass@localhost/trickedbot
4+
5+
WITH include drop, create tables, create indexes, reset sequences
6+
7+
CAST
8+
type integer to bigint,
9+
column user.id to bigint using (lambda (v) (parse-integer v)),
10+
column memory.user_id to bigint
11+
12+
ALTER SCHEMA 'main' RENAME TO 'public';

src/ai_message.rs

Lines changed: 26 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use openrouter_api::{
44
types::chat::{ChatCompletionRequest, Message, MessageContent},
55
OpenRouterClient,
66
};
7-
use r2d2_sqlite::SqliteConnectionManager;
8-
use serde_rusqlite::from_row;
7+
use deadpool_postgres::Pool;
8+
use crate::db;
99
use tokio::sync::mpsc;
1010

1111
use crate::{
@@ -79,54 +79,6 @@ fn build_authors_note() -> &'static str {
7979
**Current Mode:** Trickster (unless Sebook detected)]"#
8080
}
8181

82-
/// Retrieves relevant memories for the user from the database
83-
/// Reference: AGENT_GUIDE.md section on Memory Context Injection
84-
fn get_user_memories(database: &r2d2::Pool<SqliteConnectionManager>, user_id: u64) -> Result<Vec<Memory>> {
85-
let db = database.get()?;
86-
let mut statement = db.prepare("SELECT * FROM memory WHERE user_id = ? ORDER BY id DESC LIMIT 5")?;
87-
88-
let memories: Vec<Memory> = statement
89-
.query_map([user_id.to_string()], |row| {
90-
from_row::<Memory>(row).map_err(|_| rusqlite::Error::QueryReturnedNoRows)
91-
})?
92-
.filter_map(Result::ok)
93-
.collect();
94-
95-
Ok(memories)
96-
}
97-
98-
/// Fetches relationships for users mentioned in the conversation
99-
fn get_users_with_relationships(database: &r2d2::Pool<SqliteConnectionManager>, context: &str) -> Result<Vec<(String, String)>> {
100-
let db = database.get()?;
101-
let mut statement = db.prepare("SELECT name, relationship FROM user WHERE relationship != ''")?;
102-
103-
let users: Vec<(String, String)> = statement
104-
.query_map([], |row| {
105-
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
106-
})?
107-
.filter_map(Result::ok)
108-
.filter(|(name, _)| context.contains(name.as_str())) // Only include users in the conversation
109-
.collect();
110-
111-
Ok(users)
112-
}
113-
114-
/// Fetches examples for users mentioned in the conversation
115-
fn get_users_with_examples(database: &r2d2::Pool<SqliteConnectionManager>, context: &str) -> Result<Vec<(String, String, String)>> {
116-
let db = database.get()?;
117-
let mut statement = db.prepare("SELECT name, example_input, example_output FROM user WHERE example_input != '' AND example_output != ''")?;
118-
119-
let users: Vec<(String, String, String)> = statement
120-
.query_map([], |row| {
121-
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
122-
})?
123-
.filter_map(Result::ok)
124-
.filter(|(name, _, _)| context.contains(name.as_str())) // Only include users in the conversation
125-
.collect();
126-
127-
Ok(users)
128-
}
129-
13082
/// Formats memories into natural language for prompt injection with usage guidelines
13183
fn format_memories(memories: &[Memory]) -> String {
13284
if memories.is_empty() {
@@ -218,52 +170,6 @@ Quality over quantity - one great response beats three mediocre fragments."#,
218170
)
219171
}
220172

221-
/// Get user from database or return default
222-
fn get_user_or_default(database: &r2d2::Pool<SqliteConnectionManager>, user_id: u64) -> User {
223-
database
224-
.get()
225-
.ok()
226-
.and_then(|db| {
227-
db.prepare("SELECT * FROM user WHERE id = ?").ok().and_then(|mut stmt| {
228-
stmt.query_one([user_id.to_string()], |row| {
229-
from_row::<User>(row).map_err(|_| rusqlite::Error::QueryReturnedNoRows)
230-
})
231-
.ok()
232-
})
233-
})
234-
.unwrap_or_else(|| User {
235-
id: user_id,
236-
level: 0,
237-
xp: 0,
238-
social_credit: 0,
239-
name: "Unknown".to_owned(),
240-
relationship: String::new(),
241-
example_input: String::new(),
242-
example_output: String::new(),
243-
})
244-
}
245-
246-
/// Replace user mentions in context with actual usernames
247-
fn replace_mentions(
248-
context: String,
249-
user_mentions: &HashMap<String, u64>,
250-
database: &r2d2::Pool<SqliteConnectionManager>,
251-
) -> String {
252-
let mut processed = context;
253-
for (mention, user_id) in user_mentions {
254-
if let Ok(db) = database.get() {
255-
if let Ok(mut stmt) = db.prepare("SELECT * FROM user WHERE id = ?") {
256-
if let Ok(user) = stmt.query_one([user_id.to_string()], |row| {
257-
from_row::<User>(row).map_err(|_| rusqlite::Error::QueryReturnedNoRows)
258-
}) {
259-
processed = processed.replace(mention, &user.name);
260-
}
261-
}
262-
}
263-
}
264-
processed
265-
}
266-
267173
/// Stream AI response chunks through a channel
268174
async fn stream_ai_response(
269175
client: OpenRouterClient<openrouter_api::Ready>,
@@ -313,7 +219,7 @@ async fn stream_ai_response(
313219
}
314220

315221
pub async fn main(
316-
database: r2d2::Pool<SqliteConnectionManager>,
222+
database: Pool,
317223
user_id: u64,
318224
message: &str,
319225
context: &str,
@@ -339,14 +245,32 @@ pub async fn main(
339245
)?;
340246

341247
// Process context and get user info
342-
let processed_context = replace_mentions(context.to_string(), &user_mentions, &database);
343-
let user = get_user_or_default(&database, user_id);
344-
let memories = get_user_memories(&database, user_id)?;
248+
// Replace user mentions with names
249+
let mut processed_context = context.to_string();
250+
for (mention, &user_id_ref) in &user_mentions {
251+
if let Ok(Some(u)) = db::get_user(&database, user_id_ref).await {
252+
processed_context = processed_context.replace(mention, &u.name);
253+
}
254+
}
255+
256+
let user = db::get_user(&database, user_id).await?.unwrap_or_else(|| crate::database::User {
257+
id: user_id as i64,
258+
level: 0,
259+
xp: 0,
260+
social_credit: 0,
261+
name: "Unknown".to_owned(),
262+
relationship: String::new(),
263+
example_input: String::new(),
264+
example_output: String::new(),
265+
});
266+
let memories = db::get_memories(&database, user_id).await.unwrap_or_default();
345267
let formatted_memories = format_memories(&memories);
346268

347269
// Fetch dynamic relationship and example data for users in the conversation
348-
let users_with_relationships = get_users_with_relationships(&database, &processed_context).unwrap_or_default();
349-
let users_with_examples = get_users_with_examples(&database, &processed_context).unwrap_or_default();
270+
let users_with_relationships = db::get_users_with_relationships(&database).await.unwrap_or_default()
271+
.into_iter().filter(|(name, _)| processed_context.contains(name.as_str())).collect::<Vec<_>>();
272+
let users_with_examples = db::get_users_with_examples(&database).await.unwrap_or_default()
273+
.into_iter().filter(|(name, _, _)| processed_context.contains(name.as_str())).collect::<Vec<_>>();
350274

351275
// Build prompt
352276
let system_prompt = build_character_prompt(

0 commit comments

Comments
 (0)