Migration 0004 — authors + author_revisions + stories.author_id + stories.author_revision_id + stories.cross_story_memory + author_corpus. Soul versioning built in from day one per Sulkta's locked decisions: - authors.id immutable identity (slug + display_name + tagline + model) - author_revisions tracks each soul revision with n monotonic - Partial unique index 'idx_author_revisions_current' enforces exactly one is_current=true per author - stories.author_revision_id pins to the exact soul used at gen time (so 'this was the Orson Black active when chapter 8 was written' is always recoverable) - author_corpus tracks 'authored' + 'read' relationships for the v0.3 cross-story memory toggle skald-core::authors module — CRUD: get_by_slug, get_with_current_revision, get_current_revision, get_revision, create_or_get (idempotent), add_revision (transactional, demotes prior is_current=true), assign_to_story (also touches author_corpus). Web v0.1 forms (the second feedback bucket — 'no way to make new stories', 'no options for sequels'): handlers + form panels + POST routes for /stories/new and /stories/:id/continue. Both create a story stub with status='seed'; actual generation will be fired by 'skald continue' (next commit) walking seed rows. Norse visual revamp + mobile collapse deferred — vetting full gen is the priority per Sulkta's 'green light for v0.3'. Coming back to the aesthetic after the pipeline works end-to-end against a real Orson Black-authored Chapter 8 of Coast-Down.
263 lines
7.7 KiB
Rust
263 lines
7.7 KiB
Rust
//! 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<String>,
|
|
pub model: String,
|
|
pub is_synthetic: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub tools: Vec<String>,
|
|
pub note: Option<String>,
|
|
pub is_current: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// 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<Option<Author>> {
|
|
let row: Option<(Uuid, String, String, Option<String>, String, bool, DateTime<Utc>, DateTime<Utc>)> =
|
|
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<Option<AuthorWithRevision>> {
|
|
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<Option<Revision>> {
|
|
let row: Option<(Uuid, Uuid, i32, String, Option<String>, Vec<String>, Option<String>, bool, DateTime<Utc>)> =
|
|
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<Option<Revision>> {
|
|
let row: Option<(Uuid, Uuid, i32, String, Option<String>, Vec<String>, Option<String>, bool, DateTime<Utc>)> =
|
|
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<Author> {
|
|
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<Revision> {
|
|
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.
|
|
}
|