S1: Colony backend skeleton — Axum + SQLite, channels + messages CRUD

Monorepo structure:
- crates/colony-types: API types (serde + ts-rs), separate from DB models
- crates/colony: Axum server, SQLite via sqlx, migrations

Working endpoints:
- GET /api/health
- GET/POST /api/channels
- GET /api/channels/{id}
- GET /api/channels/{id}/messages (?since=, ?type=, ?user_id=)
- POST /api/channels/{id}/messages (with type + metadata)

Data model includes:
- seq monotonic ordering, soft delete, same-channel reply constraint
- Seeded users (benji, neeraj) and #general channel

Also: codex-review skill, .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 18:54:43 +02:00
parent 983221df33
commit e940afde52
11 changed files with 3144 additions and 0 deletions

106
crates/colony/src/db.rs Normal file
View File

@@ -0,0 +1,106 @@
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use uuid::Uuid;
/// DB row types — these map directly to SQL tables.
/// Separate from API types in colony-types.
#[derive(Debug, FromRow)]
pub struct UserRow {
pub id: String,
pub username: String,
pub display_name: String,
pub role: String,
pub password_hash: Option<String>,
pub created_at: String,
}
#[derive(Debug, FromRow)]
pub struct ChannelRow {
pub id: String,
pub name: String,
pub description: String,
pub created_by: String,
pub created_at: String,
}
#[derive(Debug, FromRow)]
pub struct MessageRow {
pub id: String,
pub seq: i64,
pub channel_id: String,
pub user_id: String,
pub r#type: String,
pub content: String,
pub metadata: Option<String>,
pub reply_to: Option<String>,
pub created_at: String,
pub updated_at: Option<String>,
pub deleted_at: Option<String>,
}
// ── Conversions from DB rows to API types ──
impl UserRow {
pub fn to_api(&self) -> colony_types::User {
colony_types::User {
id: Uuid::parse_str(&self.id).unwrap(),
username: self.username.clone(),
display_name: self.display_name.clone(),
role: match self.role.as_str() {
"agent" => colony_types::UserRole::Agent,
_ => colony_types::UserRole::Ape,
},
created_at: self.created_at.parse::<DateTime<Utc>>().unwrap(),
}
}
}
impl ChannelRow {
pub fn to_api(&self) -> colony_types::Channel {
colony_types::Channel {
id: Uuid::parse_str(&self.id).unwrap(),
name: self.name.clone(),
description: self.description.clone(),
created_by: Uuid::parse_str(&self.created_by).unwrap(),
created_at: self.created_at.parse::<DateTime<Utc>>().unwrap(),
}
}
}
impl MessageRow {
pub fn to_api(&self, user: &UserRow) -> colony_types::Message {
colony_types::Message {
id: Uuid::parse_str(&self.id).unwrap(),
seq: self.seq,
channel_id: Uuid::parse_str(&self.channel_id).unwrap(),
user: user.to_api(),
r#type: match self.r#type.as_str() {
"code" => colony_types::MessageType::Code,
"result" => colony_types::MessageType::Result,
"error" => colony_types::MessageType::Error,
"plan" => colony_types::MessageType::Plan,
_ => colony_types::MessageType::Text,
},
content: if self.deleted_at.is_some() {
"[deleted]".to_string()
} else {
self.content.clone()
},
metadata: self
.metadata
.as_ref()
.and_then(|m| serde_json::from_str(m).ok()),
reply_to: self.reply_to.as_ref().and_then(|r| Uuid::parse_str(r).ok()),
created_at: self.created_at.parse::<DateTime<Utc>>().unwrap(),
updated_at: self
.updated_at
.as_ref()
.and_then(|t| t.parse::<DateTime<Utc>>().ok()),
deleted_at: self
.deleted_at
.as_ref()
.and_then(|t| t.parse::<DateTime<Utc>>().ok()),
}
}
}

53
crates/colony/src/main.rs Normal file
View File

@@ -0,0 +1,53 @@
mod db;
mod routes;
use axum::{routing::get, Router};
use sqlx::sqlite::SqlitePoolOptions;
use std::env;
#[tokio::main]
async fn main() {
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:colony.db?mode=rwc".into());
let port = env::var("PORT").unwrap_or_else(|_| "3001".into());
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(&db_url)
.await
.expect("Failed to connect to database");
// Enable WAL mode
sqlx::query("PRAGMA journal_mode=WAL")
.execute(&pool)
.await
.unwrap();
// Run migrations
let migration_sql = include_str!("../migrations/001_init.sql");
for statement in migration_sql.split(';') {
let stmt = statement.trim();
if !stmt.is_empty() {
if let Err(e) = sqlx::query(stmt).execute(&pool).await {
eprintln!("Migration warning (may be OK): {}", e);
}
}
}
println!("Colony running on port {}", port);
let app = Router::new()
.route("/api/health", get(routes::health))
.route("/api/channels", get(routes::list_channels).post(routes::create_channel))
.route("/api/channels/{id}", get(routes::get_channel))
.route(
"/api/channels/{channel_id}/messages",
get(routes::list_messages).post(routes::post_message),
)
.with_state(pool);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}

227
crates/colony/src/routes.rs Normal file
View File

@@ -0,0 +1,227 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use colony_types::*;
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::db::*;
// ── Health ──
pub async fn health() -> &'static str {
"ok"
}
// ── Channels ──
pub async fn list_channels(State(db): State<SqlitePool>) -> impl IntoResponse {
let rows = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels ORDER BY created_at")
.fetch_all(&db)
.await
.unwrap();
let channels: Vec<Channel> = rows.iter().map(|r| r.to_api()).collect();
Json(channels)
}
pub async fn create_channel(
State(db): State<SqlitePool>,
Json(body): Json<CreateChannel>,
) -> impl IntoResponse {
let id = Uuid::new_v4().to_string();
// Hardcoded to benji for now (no auth yet)
let created_by = "00000000-0000-0000-0000-000000000001";
sqlx::query("INSERT INTO channels (id, name, description, created_by) VALUES (?, ?, ?, ?)")
.bind(&id)
.bind(&body.name)
.bind(&body.description)
.bind(created_by)
.execute(&db)
.await
.unwrap();
let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?")
.bind(&id)
.fetch_one(&db)
.await
.unwrap();
(StatusCode::CREATED, Json(row.to_api()))
}
pub async fn get_channel(
State(db): State<SqlitePool>,
Path(id): Path<String>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?")
.bind(&id)
.fetch_optional(&db)
.await
.unwrap();
match row {
Some(r) => Ok(Json(r.to_api())),
None => Err(StatusCode::NOT_FOUND),
}
}
// ── Messages ──
pub async fn list_messages(
State(db): State<SqlitePool>,
Path(channel_id): Path<String>,
Query(query): Query<MessageQuery>,
) -> impl IntoResponse {
// Build query dynamically based on filters
let mut sql = String::from(
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
FROM messages m JOIN users u ON m.user_id = u.id \
WHERE m.channel_id = ?",
);
let mut binds: Vec<String> = vec![channel_id.clone()];
if let Some(since) = &query.since {
sql.push_str(" AND m.created_at > ?");
binds.push(since.to_rfc3339());
}
if let Some(msg_type) = &query.r#type {
sql.push_str(" AND m.type = ?");
binds.push(match msg_type {
MessageType::Text => "text",
MessageType::Code => "code",
MessageType::Result => "result",
MessageType::Error => "error",
MessageType::Plan => "plan",
}.to_string());
}
if let Some(user_id) = &query.user_id {
sql.push_str(" AND m.user_id = ?");
binds.push(user_id.to_string());
}
sql.push_str(" ORDER BY m.seq ASC");
// Use raw query with dynamic binds
let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql);
for b in &binds {
q = q.bind(b);
}
let rows = q.fetch_all(&db).await.unwrap();
let messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
Json(messages)
}
pub async fn post_message(
State(db): State<SqlitePool>,
Path(channel_id): Path<String>,
Json(body): Json<PostMessage>,
) -> impl IntoResponse {
let id = Uuid::new_v4().to_string();
// Hardcoded to benji for now (no auth yet)
let user_id = "00000000-0000-0000-0000-000000000001";
let msg_type = match body.r#type {
MessageType::Text => "text",
MessageType::Code => "code",
MessageType::Result => "result",
MessageType::Error => "error",
MessageType::Plan => "plan",
};
let metadata_json = body.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap());
let reply_to = body.reply_to.map(|r| r.to_string());
// Get next seq
let seq: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COALESCE((SELECT next_seq FROM channel_seq WHERE channel_id = ?), 1)",
)
.bind(&channel_id)
.fetch_one(&db)
.await
.unwrap();
sqlx::query(
"INSERT INTO messages (id, seq, channel_id, user_id, type, content, metadata, reply_to) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(seq)
.bind(&channel_id)
.bind(user_id)
.bind(msg_type)
.bind(&body.content)
.bind(&metadata_json)
.bind(&reply_to)
.execute(&db)
.await
.unwrap();
// Fetch the full message with user
let row = sqlx::query_as::<_, MessageWithUserRow>(
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?",
)
.bind(&id)
.fetch_one(&db)
.await
.unwrap();
(StatusCode::CREATED, Json(row.to_api_message()))
}
// ── Joined row type for message + user ──
#[derive(Debug, sqlx::FromRow)]
pub struct MessageWithUserRow {
// message fields
pub id: String,
pub seq: i64,
pub channel_id: String,
pub user_id: String,
pub r#type: String,
pub content: String,
pub metadata: Option<String>,
pub reply_to: Option<String>,
pub created_at: String,
pub updated_at: Option<String>,
pub deleted_at: Option<String>,
// user fields (aliased)
pub u_id: String,
pub username: String,
pub display_name: String,
pub role: String,
pub u_created_at: String,
}
impl MessageWithUserRow {
pub fn to_api_message(&self) -> Message {
let user_row = UserRow {
id: self.u_id.clone(),
username: self.username.clone(),
display_name: self.display_name.clone(),
role: self.role.clone(),
password_hash: None,
created_at: self.u_created_at.clone(),
};
let msg_row = MessageRow {
id: self.id.clone(),
seq: self.seq,
channel_id: self.channel_id.clone(),
user_id: self.user_id.clone(),
r#type: self.r#type.clone(),
content: self.content.clone(),
metadata: self.metadata.clone(),
reply_to: self.reply_to.clone(),
created_at: self.created_at.clone(),
updated_at: self.updated_at.clone(),
deleted_at: self.deleted_at.clone(),
};
msg_row.to_api(&user_row)
}
}