//! Authors module. Owns the read/write paths for the persona layer. //! //! An [`Author`] is the immutable identity (slug, display_name, model). //! A [`Revision`] is the versioned soul content — exactly one revision //! per author is marked `is_current = true` at any time. //! //! When skald-the-binary runs a forge pass, it pulls the current //! revision via [`get_with_current_revision`], stuffs the soul into //! the system prompt with `SystemMode::Replace`, and the model //! becomes the author for the duration of the call. use anyhow::{Context, bail}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Author { pub id: Uuid, pub slug: String, pub display_name: String, pub persona_tagline: Option, pub model: String, pub is_synthetic: bool, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Revision { pub id: Uuid, pub author_id: Uuid, pub n: i32, pub soul: String, pub system_template: Option, pub tools: Vec, pub note: Option, pub is_current: bool, pub created_at: DateTime, } /// An author + their currently-active revision. Most pipeline calls /// want both at once. #[derive(Debug, Clone)] pub struct AuthorWithRevision { pub author: Author, pub revision: Revision, } /// Fetch an author by slug. Returns None if no such author. pub async fn get_by_slug(pool: &PgPool, slug: &str) -> anyhow::Result> { let row: Option<(Uuid, String, String, Option, String, bool, DateTime, DateTime)> = sqlx::query_as( "SELECT id, slug, display_name, persona_tagline, model, is_synthetic, created_at, updated_at FROM authors WHERE slug = $1", ) .bind(slug) .fetch_optional(pool) .await?; Ok(row.map( |(id, slug, display_name, persona_tagline, model, is_synthetic, created_at, updated_at)| { Author { id, slug, display_name, persona_tagline, model, is_synthetic, created_at, updated_at, } }, )) } /// Fetch an author + their current revision in one call. pub async fn get_with_current_revision( pool: &PgPool, slug: &str, ) -> anyhow::Result> { let Some(author) = get_by_slug(pool, slug).await? else { return Ok(None); }; let revision = get_current_revision(pool, author.id) .await? .with_context(|| format!("author {slug} has no current revision"))?; Ok(Some(AuthorWithRevision { author, revision })) } /// Fetch the current revision for an author. pub async fn get_current_revision( pool: &PgPool, author_id: Uuid, ) -> anyhow::Result> { let row: Option<(Uuid, Uuid, i32, String, Option, Vec, Option, bool, DateTime)> = sqlx::query_as( "SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at FROM author_revisions WHERE author_id = $1 AND is_current = true", ) .bind(author_id) .fetch_optional(pool) .await?; Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| { Revision { id, author_id, n, soul, system_template, tools, note, is_current, created_at, } })) } /// Fetch a specific revision by id (e.g. the one pinned on a story). pub async fn get_revision(pool: &PgPool, revision_id: Uuid) -> anyhow::Result> { let row: Option<(Uuid, Uuid, i32, String, Option, Vec, Option, bool, DateTime)> = sqlx::query_as( "SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at FROM author_revisions WHERE id = $1", ) .bind(revision_id) .fetch_optional(pool) .await?; Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| { Revision { id, author_id, n, soul, system_template, tools, note, is_current, created_at, } })) } /// Create a new author. Idempotent on slug — if an author with this /// slug exists, returns it instead. pub async fn create_or_get( pool: &PgPool, slug: &str, display_name: &str, persona_tagline: Option<&str>, ) -> anyhow::Result { if let Some(existing) = get_by_slug(pool, slug).await? { return Ok(existing); } sqlx::query( "INSERT INTO authors (slug, display_name, persona_tagline) VALUES ($1, $2, $3)", ) .bind(slug) .bind(display_name) .bind(persona_tagline) .execute(pool) .await?; get_by_slug(pool, slug) .await? .with_context(|| format!("author {slug} just created but not found")) } /// Add a new revision to an author and mark it current. The previous /// current revision (if any) gets flipped to is_current=false. pub async fn add_revision( pool: &PgPool, author_id: Uuid, soul: &str, system_template: Option<&str>, tools: &[String], note: Option<&str>, ) -> anyhow::Result { let mut tx = pool.begin().await?; // Demote the current revision if there is one. sqlx::query("UPDATE author_revisions SET is_current = false WHERE author_id = $1 AND is_current = true") .bind(author_id) .execute(&mut *tx) .await?; // Next n. let next_n: i32 = sqlx::query_scalar( "SELECT COALESCE(MAX(n), 0) + 1 FROM author_revisions WHERE author_id = $1", ) .bind(author_id) .fetch_one(&mut *tx) .await?; let revision_id: Uuid = sqlx::query_scalar( "INSERT INTO author_revisions (author_id, n, soul, system_template, tools, note, is_current) VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id", ) .bind(author_id) .bind(next_n) .bind(soul) .bind(system_template) .bind(tools) .bind(note) .fetch_one(&mut *tx) .await?; tx.commit().await?; get_revision(pool, revision_id) .await? .with_context(|| format!("revision {revision_id} just inserted but not found")) } /// Assign an author + revision to an existing story. Pins the /// revision so future re-reads always know which version wrote /// what. Also adds a row to author_corpus marking the author as /// having authored this story. pub async fn assign_to_story( pool: &PgPool, story_id: Uuid, author_id: Uuid, revision_id: Uuid, ) -> anyhow::Result<()> { let mut tx = pool.begin().await?; let affected = sqlx::query( "UPDATE stories SET author_id = $1, author_revision_id = $2 WHERE id = $3", ) .bind(author_id) .bind(revision_id) .bind(story_id) .execute(&mut *tx) .await?; if affected.rows_affected() == 0 { bail!("story {story_id} not found"); } sqlx::query( "INSERT INTO author_corpus (author_id, story_id, role) VALUES ($1, $2, 'authored') ON CONFLICT (author_id, story_id) DO NOTHING", ) .bind(author_id) .bind(story_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) } #[cfg(test)] mod tests { // DB-touching tests live in skald/tests/integration.rs against a // real ephemeral postgres. Unit tests would mostly check trivial // mappings. }