chore: scaffold moments workspace

Cargo workspace with five crates per architecture conventions:

- moments-entities: Source enum, Event, EventQuery, SourceSummary
- moments-core:     EventReader / EventWriter ports
- moments-data:     PgStore (sqlx postgres adapter) + 0001_init.sql
- moments-api:      axum binary; /v1/{healthz,events,sources}
- moments-worker:   skeleton; pollers land in step 2

Sources committed-to for ingestion: github, gitea, hg, bugzilla.
Workstation events explicitly retired (not deferred).

Build + clippy clean. sqlx queries use the runtime API for now;
will switch to compile-time-checked macros + .sqlx offline cache
once magrathea has the moments_{ro,rw} roles and database created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 17:47:06 +03:00
commit 6775309043
16 changed files with 3580 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
[package]
name = "moments-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
moments-entities.workspace = true
moments-core.workspace = true
moments-data.workspace = true
tokio.workspace = true
axum.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
clap.workspace = true

View File

@@ -0,0 +1,146 @@
use std::{net::SocketAddr, sync::Arc};
use axum::{
Json, Router,
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
};
use chrono::{DateTime, Utc};
use clap::Parser;
use moments_core::EventReader;
use moments_data::PgStore;
use moments_entities::{Event, EventQuery, Source, SourceSummary};
use serde::Deserialize;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::info;
#[derive(Parser, Debug)]
#[command(version, about = "moments read-only HTTP API")]
struct Args {
#[arg(long, env = "BIND_ADDR", default_value = "127.0.0.1:8080")]
bind: SocketAddr,
#[arg(long, env = "DATABASE_URL")]
database_url: String,
}
#[derive(Clone)]
struct AppState {
store: Arc<PgStore>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
let args = Args::parse();
let store = PgStore::connect(&args.database_url).await?;
store.migrate().await?;
let state = AppState {
store: Arc::new(store),
};
let app = Router::new()
.route("/v1/healthz", get(healthz))
.route("/v1/events", get(list_events))
.route("/v1/sources", get(list_sources))
.with_state(state)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
info!(addr = %args.bind, "listening");
let listener = tokio::net::TcpListener::bind(args.bind).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let json = std::env::var("JOURNAL_STREAM").is_ok();
if json {
fmt().with_env_filter(filter).json().init();
} else {
fmt().with_env_filter(filter).init();
}
}
async fn healthz() -> &'static str {
"ok"
}
#[derive(Debug, Deserialize)]
struct EventsQueryParams {
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
/// Comma-separated list, e.g. `source=github,gitea`.
source: Option<String>,
limit: Option<u32>,
}
async fn list_events(
State(state): State<AppState>,
Query(params): Query<EventsQueryParams>,
) -> Result<Json<Vec<Event>>, ApiError> {
let sources = params
.source
.as_deref()
.map(parse_sources)
.transpose()?;
let limit = params.limit.unwrap_or(100).clamp(1, 1000);
let query = EventQuery {
from: params.from,
to: params.to,
sources,
limit,
};
let events = state.store.list_events(&query).await.map_err(internal)?;
Ok(Json(events))
}
async fn list_sources(
State(state): State<AppState>,
) -> Result<Json<Vec<SourceSummary>>, ApiError> {
let summaries = state.store.source_summaries().await.map_err(internal)?;
Ok(Json(summaries))
}
fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.parse::<Source>().map_err(|e| ApiError::bad_request(e.to_string())))
.collect()
}
struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad_request(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: msg.into(),
}
}
}
fn internal<E: std::fmt::Display>(e: E) -> ApiError {
ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: e.to_string(),
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
}
}