//// SQLite storage adapter for Telega.
////
//// Implements `telega/storage.KeyValueStorage` on top of a single SQLite
//// table, giving small bots single-file persistence that survives restarts.
//// TTL is stored as an epoch-millisecond `expires_at` column and enforced
//// lazily on access (`get`/`scan`), matching the core ETS backend.
import gleam/dynamic/decode
import gleam/erlang/atom
import gleam/option.{type Option, None, Some}
import sqlight
import telega/storage.{type KeyValueStorage, KeyValueStorage}
/// Build a `KeyValueStorage` backed by the given SQLite connection.
///
/// Uses the default table name `telega_storage`. Call `migrate` once at startup
/// to create the table.
pub fn new(conn: sqlight.Connection) -> KeyValueStorage(sqlight.Error) {
new_with_table(conn, default_table)
}
/// Like `new`, but with a custom table name.
pub fn new_with_table(
conn: sqlight.Connection,
table: String,
) -> KeyValueStorage(sqlight.Error) {
KeyValueStorage(
get: fn(key) { do_get(conn, table, key) },
set: fn(key, value) { do_set(conn, table, key, value, None) },
set_with_ttl: fn(key, value, ttl_ms) {
do_set(conn, table, key, value, Some(now_ms() + ttl_ms))
},
delete: fn(key) { do_delete(conn, table, key) },
scan: fn(prefix) { do_scan(conn, table, prefix) },
)
}
const default_table = "telega_storage"
/// Create the storage table if it does not exist. Run once at startup.
pub fn migrate(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) {
migrate_table(conn, default_table)
}
/// Create a custom-named storage table if it does not exist.
pub fn migrate_table(
conn: sqlight.Connection,
table: String,
) -> Result(Nil, sqlight.Error) {
sqlight.exec(
"CREATE TABLE IF NOT EXISTS "
<> table
<> " (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)",
conn,
)
}
fn do_get(
conn: sqlight.Connection,
table: String,
key: String,
) -> Result(Option(String), sqlight.Error) {
let decoder = {
use value <- decode.field(0, decode.string)
use expires_at <- decode.field(1, decode.optional(decode.int))
decode.success(#(value, expires_at))
}
let sql =
"SELECT value, expires_at FROM " <> table <> " WHERE key = ? LIMIT 1"
case
sqlight.query(sql, on: conn, with: [sqlight.text(key)], expecting: decoder)
{
Ok([#(value, expires_at), ..]) ->
case is_live(expires_at) {
True -> Ok(Some(value))
False -> {
// Lazily drop the expired row, then report it as absent.
case do_delete(conn, table, key) {
Ok(_) -> Ok(None)
Error(err) -> Error(err)
}
}
}
Ok([]) -> Ok(None)
Error(err) -> Error(err)
}
}
fn do_set(
conn: sqlight.Connection,
table: String,
key: String,
value: String,
expires_at: Option(Int),
) -> Result(Nil, sqlight.Error) {
let sql =
"INSERT INTO "
<> table
<> " (key, value, expires_at) VALUES (?, ?, ?)"
<> " ON CONFLICT(key) DO UPDATE SET value = excluded.value,"
<> " expires_at = excluded.expires_at"
let args = [
sqlight.text(key),
sqlight.text(value),
sqlight.nullable(sqlight.int, expires_at),
]
case sqlight.query(sql, on: conn, with: args, expecting: decode.dynamic) {
Ok(_) -> Ok(Nil)
Error(err) -> Error(err)
}
}
fn do_delete(
conn: sqlight.Connection,
table: String,
key: String,
) -> Result(Nil, sqlight.Error) {
let sql = "DELETE FROM " <> table <> " WHERE key = ?"
case
sqlight.query(
sql,
on: conn,
with: [sqlight.text(key)],
expecting: decode.dynamic,
)
{
Ok(_) -> Ok(Nil)
Error(err) -> Error(err)
}
}
fn do_scan(
conn: sqlight.Connection,
table: String,
prefix: String,
) -> Result(List(String), sqlight.Error) {
let sql =
"SELECT key FROM "
<> table
<> " WHERE key LIKE ? AND (expires_at IS NULL OR expires_at > ?)"
let args = [sqlight.text(prefix <> "%"), sqlight.int(now_ms())]
sqlight.query(
sql,
on: conn,
with: args,
expecting: decode.at([0], decode.string),
)
}
/// An absent (`None`) `expires_at` means "never expires".
fn is_live(expires_at: Option(Int)) -> Bool {
case expires_at {
None -> True
Some(at) -> now_ms() < at
}
}
fn now_ms() -> Int {
os_system_time(atom.create("millisecond"))
}
@external(erlang, "os", "system_time")
fn os_system_time(unit: atom.Atom) -> Int