Persist chat history to Postgres via durable JetStream consumer

DATABASE_URL required at startup; schema self-initializes.
Compose gains a postgres service for standalone dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-08 22:14:26 +02:00
co-authored by Claude Fable 5
parent 6b97ec3b0f
commit ec3771f2e7
10 changed files with 696 additions and 21 deletions
+2
View File
@@ -1,5 +1,6 @@
pub mod oidc;
pub mod sse;
pub mod store;
use axum::extract::FromRef;
use leptos::prelude::LeptosOptions;
@@ -10,6 +11,7 @@ pub struct AppState {
pub leptos_options: LeptosOptions,
pub nats: async_nats::Client,
pub oidc: Arc<oidc::Oidc>,
pub pool: sqlx::PgPool,
}
impl FromRef<AppState> for LeptosOptions {
+152
View File
@@ -0,0 +1,152 @@
//! Message archive: a durable JetStream pull consumer drains `chat.room.*`
//! into Postgres, so history survives restarts and includes messages
//! published by any client on the bus (not just this app).
use std::time::Duration;
use async_nats::jetstream;
use futures::StreamExt;
use sqlx::PgPool;
use crate::chat::ChatMessage;
const STREAM_NAME: &str = "CHAT";
const CONSUMER_NAME: &str = "cnats-postgres";
pub async fn init_schema(pool: &PgPool) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
room TEXT NOT NULL,
username TEXT NOT NULL,
display_name TEXT NOT NULL,
text TEXT NOT NULL,
time TEXT NOT NULL,
ts BIGINT NOT NULL
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS messages_room_ts ON messages (room, ts DESC, id)",
)
.execute(pool)
.await?;
Ok(())
}
/// Runs forever; (re)creates the stream/consumer and retries on any failure,
/// so a NATS or Postgres outage never takes the chat server down.
pub async fn run_consumer(nats: async_nats::Client, pool: PgPool) {
loop {
if let Err(err) = consume(&nats, &pool).await {
tracing::error!("archive consumer failed: {err:#}; retrying in 5s");
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
async fn consume(nats: &async_nats::Client, pool: &PgPool) -> anyhow::Result<()> {
let js = jetstream::new(nats.clone());
let stream = js
.get_or_create_stream(jetstream::stream::Config {
name: STREAM_NAME.to_string(),
subjects: vec!["chat.room.*".to_string()],
..Default::default()
})
.await
.map_err(|e| anyhow::anyhow!("get_or_create_stream: {e}"))?;
let consumer = stream
.get_or_create_consumer(
CONSUMER_NAME,
jetstream::consumer::pull::Config {
durable_name: Some(CONSUMER_NAME.to_string()),
..Default::default()
},
)
.await
.map_err(|e| anyhow::anyhow!("get_or_create_consumer: {e}"))?;
tracing::info!(stream = STREAM_NAME, consumer = CONSUMER_NAME, "archiving to postgres");
let mut messages = consumer
.messages()
.await
.map_err(|e| anyhow::anyhow!("consumer messages: {e}"))?;
while let Some(msg) = messages.next().await {
let msg = msg.map_err(|e| anyhow::anyhow!("pull next: {e}"))?;
match serde_json::from_slice::<ChatMessage>(&msg.payload) {
Ok(chat) => insert(pool, &chat).await?,
// Malformed payloads (e.g. hand-typed CLI publishes) are logged
// and acked so they don't wedge the consumer.
Err(err) => tracing::warn!("skipping unparseable message: {err}"),
}
msg.ack()
.await
.map_err(|e| anyhow::anyhow!("ack: {e}"))?;
}
Ok(())
}
async fn insert(pool: &PgPool, m: &ChatMessage) -> anyhow::Result<()> {
// Idempotent on message id: JetStream is at-least-once, so redeliveries
// after an unacked crash must not duplicate rows.
sqlx::query(
"INSERT INTO messages (id, room, username, display_name, text, time, ts)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING",
)
.bind(&m.id)
.bind(&m.room)
.bind(&m.username)
.bind(&m.display_name)
.bind(&m.text)
.bind(&m.time)
.bind(m.ts)
.execute(pool)
.await?;
Ok(())
}
/// The latest `limit` messages for a room, oldest first.
pub async fn recent(pool: &PgPool, room: &str, limit: i64) -> anyhow::Result<Vec<ChatMessage>> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
room: String,
username: String,
display_name: String,
text: String,
time: String,
ts: i64,
}
let mut rows: Vec<Row> = sqlx::query_as(
"SELECT id, room, username, display_name, text, time, ts
FROM messages
WHERE room = $1
ORDER BY ts DESC, id
LIMIT $2",
)
.bind(room)
.bind(limit)
.fetch_all(pool)
.await?;
rows.reverse();
Ok(rows
.into_iter()
.map(|r| ChatMessage {
id: r.id,
room: r.room,
username: r.username,
display_name: r.display_name,
text: r.text,
time: r.time,
ts: r.ts,
})
.collect())
}