Users, file manager, image generation, budgets and agent workflow

- User management: local accounts (Argon2) and OpenID Connect, admin/user
  roles, per-user folders plus admin-shared folders, read-only session
  sharing, event filtering per user
- File manager with CodeMirror editor and git client (status, diff,
  stage, commit, branches, fetch/pull/push, log, clone/init)
- Image generation via fal.ai: generate_image tool and image page
- Budgets: monthly limits per provider, provider balances/costs
  (OpenRouter, Anthropic admin cost report, fal.ai), price discovery
- Subagents on other models, background tasks, concurrency limits per
  host/model/subagent
- MCP tools loaded on demand (mcp_search/mcp_call), read-only hints
- Permission mode "auto", safe read-only commands always allowed
- Persistent message queue with attachments, deletable
- Live reasoning incl. <think> tags, activity animations, CSS themes,
  mobile layout, statistics per user, upload size handling
- Docker: named volume for state, localhost-bound port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+473 -18
View File
@@ -50,7 +50,74 @@ impl Store {
error INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS usage_ts ON usage(ts);
CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);",
CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT,
role TEXT NOT NULL DEFAULT 'user',
oidc_sub TEXT UNIQUE,
disabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
user_agent TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS folder_access (
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (folder_id, user_id)
);
CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
user_id TEXT,
session_id TEXT,
prompt TEXT NOT NULL,
model TEXT NOT NULL,
path TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
CREATE TABLE IF NOT EXISTS queue (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
data TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (session_id, user_id)
);",
)?;
// Columns added after the first release.
for (table, column, ddl) in [
("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"),
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
] {
let exists: bool = conn
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
.exists([column])?;
if !exists {
conn.execute(ddl, [])?;
}
}
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS sessions_owner ON sessions(owner_id);
CREATE INDEX IF NOT EXISTS usage_user ON usage(user_id);",
)?;
Ok(Self { conn: Mutex::new(conn) })
}
@@ -58,9 +125,10 @@ impl Store {
pub fn save_session(&self, s: &Session) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sessions (id, data, parent_id, updated_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at",
params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at],
"INSERT INTO sessions (id, data, parent_id, updated_at, owner_id) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at,
owner_id = excluded.owner_id",
params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at, s.owner_id],
)?;
Ok(())
}
@@ -74,12 +142,15 @@ impl Store {
}
/// Top-level sessions (subagent sessions are reachable through their parent).
pub fn list_sessions(&self) -> Result<Vec<Session>> {
/// `user = None` lists all sessions (admins); otherwise own and shared ones.
pub fn list_sessions(&self, user: Option<&str>) -> Result<Vec<Session>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT data FROM sessions WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 500",
"SELECT data FROM sessions WHERE parent_id IS NULL AND (?1 IS NULL OR owner_id = ?1
OR id IN (SELECT session_id FROM session_shares WHERE user_id = ?1))
ORDER BY updated_at DESC LIMIT 500",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let rows = stmt.query_map([user], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for row in rows {
out.push(serde_json::from_str(&row?)?);
@@ -197,8 +268,9 @@ impl Store {
conn.execute(
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
ttft_ms, duration_ms, cost, error)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
ttft_ms, duration_ms, cost, error, user_id)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
(SELECT owner_id FROM sessions WHERE id = ?3))",
params![
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
@@ -232,21 +304,22 @@ impl Store {
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
/// into the viewer's local time.
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64) -> Result<Value> {
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
let conn = self.conn.lock().unwrap();
let shift = tz_offset_min * 60_000;
let total = conn.query_row(
&format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2"),
params![from, to],
&format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND (?3 IS NULL OR user_id = ?3)"),
params![from, to, user],
|r| agg_row(r, 0),
)?;
let grouped = |key_sql: &str, key_name: &str| -> Result<Vec<Value>> {
let mut stmt = conn.prepare(&format!(
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 GROUP BY 1 ORDER BY 1"
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3
AND (?4 IS NULL OR user_id = ?4) GROUP BY 1 ORDER BY 1"
))?;
let rows = stmt
.query_map(params![from, to, shift], |r| {
.query_map(params![from, to, shift, user], |r| {
let mut v = agg_row(r, 1)?;
v[key_name] = match r.get_ref(0)? {
rusqlite::types::ValueRef::Integer(i) => json!(i),
@@ -264,6 +337,10 @@ impl Store {
let by_model = grouped("provider || '/' || model", "model")?;
let by_provider = grouped("provider", "provider")?;
let by_agent = grouped("agent", "agent")?;
let by_user = grouped(
"COALESCE((SELECT username FROM users WHERE users.id = usage.user_id), '(none)')",
"user",
)?;
// Heatmap: weekday (0 = Sunday) x hour of day.
let heatmap = grouped(
"CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \
@@ -274,9 +351,10 @@ impl Store {
let mut stmt = conn.prepare(&format!(
"SELECT {day_sql}, provider || '/' || model, SUM(cost),
SUM(input_tokens + cache_read_tokens + cache_write_tokens), SUM(output_tokens)
FROM usage WHERE ts >= ?1 AND ts < ?2 GROUP BY 1, 2 ORDER BY 1"
FROM usage WHERE ts >= ?1 AND ts < ?2 AND (?4 IS NULL OR user_id = ?4)
GROUP BY 1, 2 ORDER BY 1"
))?;
stmt.query_map(params![from, to, shift], |r| {
stmt.query_map(params![from, to, shift, user], |r| {
Ok(json!({
"day": r.get::<_, String>(0)?,
"model": r.get::<_, String>(1)?,
@@ -292,10 +370,10 @@ impl Store {
let mut stmt = conn.prepare(&format!(
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG}
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
WHERE u.ts >= ?1 AND u.ts < ?2
WHERE u.ts >= ?1 AND u.ts < ?2 AND (?3 IS NULL OR u.user_id = ?3)
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
))?;
stmt.query_map(params![from, to], |r| {
stmt.query_map(params![from, to, user], |r| {
let mut v = agg_row(r, 2)?;
v["session_id"] = json!(r.get::<_, String>(0)?);
v["title"] = json!(r.get::<_, String>(1)?);
@@ -310,8 +388,385 @@ impl Store {
"by_model": by_model,
"by_provider": by_provider,
"by_agent": by_agent,
"by_user": by_user,
"heatmap": heatmap,
"top_sessions": top_sessions,
}))
}
}
// --- users & auth -------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct User {
pub id: String,
pub username: String,
pub display_name: String,
pub role: String,
pub has_password: bool,
pub oidc: bool,
pub disabled: bool,
pub created_at: i64,
}
impl User {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
}
const USER_COLS: &str =
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at";
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
Ok(User {
id: r.get(0)?,
username: r.get(1)?,
display_name: r.get(2)?,
role: r.get(3)?,
has_password: r.get(4)?,
oidc: r.get(5)?,
disabled: r.get::<_, i64>(6)? != 0,
created_at: r.get(7)?,
})
}
impl Store {
pub fn user_count(&self) -> Result<i64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?)
}
pub fn create_user(&self, username: &str, display_name: &str, role: &str, password_hash: Option<&str>, oidc_sub: Option<&str>) -> Result<User> {
let id = crate::types::new_id();
{
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO users (id, username, display_name, password_hash, role, oidc_sub, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![id, username, display_name, password_hash, role, oidc_sub, now_ms()],
)?;
}
self.user(&id)?.ok_or_else(|| anyhow::anyhow!("user vanished"))
}
pub fn user(&self, id: &str) -> Result<Option<User>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), [id], user_row).optional()?)
}
pub fn user_by_name(&self, username: &str) -> Result<Option<(User, Option<String>)>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row(
&format!("SELECT {USER_COLS}, password_hash FROM users WHERE username = ?1"),
[username],
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(8)?)),
)
.optional()?)
}
pub fn user_by_oidc(&self, sub: &str) -> Result<Option<User>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE oidc_sub = ?1"), [sub], user_row).optional()?)
}
pub fn users(&self) -> Result<Vec<User>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!("SELECT {USER_COLS} FROM users ORDER BY username"))?;
let rows = stmt.query_map([], user_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
/// Update selected fields; None leaves a field unchanged.
pub fn update_user(&self, id: &str, display_name: Option<&str>, role: Option<&str>, disabled: Option<bool>, password_hash: Option<&str>, oidc_sub: Option<&str>) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE users SET display_name = COALESCE(?2, display_name), role = COALESCE(?3, role),
disabled = COALESCE(?4, disabled), password_hash = COALESCE(?5, password_hash),
oidc_sub = COALESCE(?6, oidc_sub) WHERE id = ?1",
params![id, display_name, role, disabled.map(|d| d as i64), password_hash, oidc_sub],
)?;
if disabled == Some(true) {
conn.execute("DELETE FROM auth_tokens WHERE user_id = ?1", [id])?;
}
Ok(())
}
pub fn delete_user(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM users WHERE id = ?1", [id])?;
Ok(())
}
pub fn admin_count(&self) -> Result<i64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0", [], |r| r.get(0))?)
}
pub fn add_token(&self, token_hash: &str, user_id: &str, expires_at: i64, user_agent: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?1", [now_ms()])?;
conn.execute(
"INSERT INTO auth_tokens (token_hash, user_id, created_at, expires_at, user_agent) VALUES (?1, ?2, ?3, ?4, ?5)",
params![token_hash, user_id, now_ms(), expires_at, user_agent],
)?;
Ok(())
}
/// The (enabled) user behind a valid login token.
pub fn user_by_token(&self, token_hash: &str) -> Result<Option<User>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row(
&format!(
"SELECT {} FROM auth_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ?1 AND t.expires_at > ?2 AND u.disabled = 0",
USER_COLS.split(", ").map(|c| format!("u.{c}")).collect::<Vec<_>>().join(", ")
),
params![token_hash, now_ms()],
user_row,
)
.optional()?)
}
pub fn delete_token(&self, token_hash: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM auth_tokens WHERE token_hash = ?1", [token_hash])?;
Ok(())
}
pub fn delete_user_tokens(&self, user_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM auth_tokens WHERE user_id = ?1", [user_id])?;
Ok(())
}
pub fn shares(&self, session_id: &str) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT user_id FROM session_shares WHERE session_id = ?1")?;
let rows = stmt.query_map([session_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows)
}
pub fn set_shares(&self, session_id: &str, users: &[String]) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM session_shares WHERE session_id = ?1", [session_id])?;
for u in users {
conn.execute("INSERT OR IGNORE INTO session_shares (session_id, user_id) VALUES (?1, ?2)", params![session_id, u])?;
}
Ok(())
}
/// Assign sessions without owner (created before user management) to a user.
pub fn adopt_orphans(&self, user_id: &str) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let ids: Vec<(String, String)> = {
let mut stmt = conn.prepare("SELECT id, data FROM sessions WHERE owner_id IS NULL")?;
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?
};
for (id, data) in &ids {
let mut v: Value = serde_json::from_str(data)?;
v["owner_id"] = json!(user_id);
conn.execute("UPDATE sessions SET owner_id = ?2, data = ?3 WHERE id = ?1", params![id, user_id, v.to_string()])?;
}
conn.execute(
"UPDATE usage SET user_id = ?1 WHERE user_id IS NULL",
[user_id],
)?;
Ok(ids.len())
}
}
// --- shared folders -----------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct Folder {
pub id: String,
/// Absolute path inside the workspace.
pub path: String,
pub users: Vec<String>,
pub created_at: i64,
}
impl Store {
pub fn folders(&self) -> Result<Vec<Folder>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT f.id, f.path, f.created_at, COALESCE(group_concat(a.user_id), '')
FROM folders f LEFT JOIN folder_access a ON a.folder_id = f.id GROUP BY f.id ORDER BY f.path",
)?;
let rows = stmt
.query_map([], |r| {
let users: String = r.get(3)?;
Ok(Folder {
id: r.get(0)?,
path: r.get(1)?,
created_at: r.get(2)?,
users: users.split(',').filter(|u| !u.is_empty()).map(String::from).collect(),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn add_folder(&self, path: &str) -> Result<String> {
let conn = self.conn.lock().unwrap();
let id = crate::types::new_id();
conn.execute("INSERT INTO folders (id, path, created_at) VALUES (?1, ?2, ?3)", params![id, path, now_ms()])?;
Ok(id)
}
pub fn delete_folder(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM folders WHERE id = ?1", [id])?;
Ok(())
}
pub fn set_folder_access(&self, id: &str, users: &[String]) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM folder_access WHERE folder_id = ?1", [id])?;
for u in users {
conn.execute("INSERT OR IGNORE INTO folder_access (folder_id, user_id) VALUES (?1, ?2)", params![id, u])?;
}
Ok(())
}
/// Paths of folders shared with a user.
pub fn folders_for_user(&self, user_id: &str) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT f.path FROM folders f JOIN folder_access a ON a.folder_id = f.id WHERE a.user_id = ?1 ORDER BY f.path",
)?;
let rows = stmt.query_map([user_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
Ok(rows)
}
}
// --- generated images ---------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct ImageRecord {
pub id: String,
pub user_id: Option<String>,
pub session_id: Option<String>,
pub prompt: String,
pub model: String,
pub path: String,
pub width: i64,
pub height: i64,
pub created_at: i64,
}
fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
Ok(ImageRecord {
id: r.get(0)?,
user_id: r.get(1)?,
session_id: r.get(2)?,
prompt: r.get(3)?,
model: r.get(4)?,
path: r.get(5)?,
width: r.get(6)?,
height: r.get(7)?,
created_at: r.get(8)?,
})
}
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at";
impl Store {
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at],
)?;
Ok(())
}
/// Latest images; `user = None` lists everybody's (admins).
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(&format!(
"SELECT {IMAGE_COLS} FROM images WHERE ?1 IS NULL OR user_id = ?1 ORDER BY created_at DESC LIMIT ?2"
))?;
let rows = stmt.query_map(params![user, limit], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn image(&self, id: &str) -> Result<Option<ImageRecord>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row(&format!("SELECT {IMAGE_COLS} FROM images WHERE id = ?1"), [id], image_row).optional()?)
}
pub fn delete_image(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM images WHERE id = ?1", [id])?;
Ok(())
}
}
impl Store {
/// Cost per provider since `from` (ms).
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
let rows = stmt.query_map([from], |r| Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)))?;
let mut out = std::collections::BTreeMap::new();
for r in rows {
let (p, c) = r?;
out.insert(p, c);
}
Ok(out)
}
}
// --- message queue -------------------------------------------------------------
/// A message waiting to be delivered to a busy session.
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct QueueItem {
pub id: String,
pub parts: Vec<crate::types::Part>,
/// "user" or "background" (results of background subagents).
pub source: String,
pub created_at: i64,
}
impl Store {
pub fn queue_add(&self, session_id: &str, item: &QueueItem) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO queue (id, session_id, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![item.id, session_id, serde_json::to_string(item)?, item.created_at],
)?;
Ok(())
}
pub fn queue_list(&self, session_id: &str) -> Result<Vec<QueueItem>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT data FROM queue WHERE session_id = ?1 ORDER BY created_at, rowid")?;
let rows = stmt.query_map([session_id], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(serde_json::from_str(&r?)?);
}
Ok(out)
}
/// Remove one item; false if it was already delivered.
pub fn queue_remove(&self, session_id: &str, id: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute("DELETE FROM queue WHERE session_id = ?1 AND id = ?2", params![session_id, id])? > 0)
}
/// Take all items for delivery.
pub fn queue_take(&self, session_id: &str) -> Result<Vec<QueueItem>> {
let items = self.queue_list(session_id)?;
let conn = self.conn.lock().unwrap();
for it in &items {
conn.execute("DELETE FROM queue WHERE id = ?1", [&it.id])?;
}
Ok(items)
}
}