Initial implementation: core vertical slice
Implements the core of SPEC.md — the manifest exchange, less audio-tier matching (§3) and federation (§9a), both of which the spec sequences as later work. - §2 Jmanifest format and series bundles - §3 cut matching: exact / runtime / loose tiers - §4 API, less POST /manifests/search - §5 rate limiting; §5a trust model, anonymous bearer tokens - §6 upload validation, all four stages - §7 relational storage, no JSON blob on the write path - §8 Rust + Axum + SQLite, single serialized writer, in-process job queue - §9a content addressing, computed on upload Reconciled against the system spec: - anneal_sec removed, withdrawn upstream by AR-012/AR-013. Presence follows track extent, so a track survives its own gaps and there is nothing to anneal. Its successor extinction_sec and the new gallery_scope are accepted and stored; scope enters the §7 ranking. A manifest still carrying anneal_sec is a hard 400, not silently ignored — it came from a pipeline whose window semantics differ from what this server assumes. - Audio signature: media under 120 s now emits no signature at all, matching scene-actor-extraction IR-007. The earlier §3 draft allowed a shortened window under 150 s, which was the weaker rule — a caller-varying length is the property SR-004 forbids. - UR IDs regularised to UR-nnn; docs/requirements.md registers 32 requirements, each tracing to an SR-nnn or PR-nnn. 189 tests: unit, end-to-end through the real router, and an injection suite covering SQL, JSON, header and Unicode payloads. Writing that suite found two real gaps, both fixed here: compatibility homoglyphs passed the §5a character class, and a one-frame audio signature was accepted on a feature-length item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+168
@@ -0,0 +1,168 @@
|
||||
//! Database access.
|
||||
//!
|
||||
//! §8 imposes two structural requirements that this module exists to satisfy:
|
||||
//!
|
||||
//! 1. **A single writer connection, serialized through one owner**, with a read
|
||||
//! pool alongside. SQLite permits only one writer at a time even in WAL mode;
|
||||
//! pointing a multi-connection pool at writes and relying on `busy_timeout`
|
||||
//! to sort it out is explicitly rejected by the spec. Here the writer lives
|
||||
//! behind a `Mutex`, so contention queues in Rust rather than surfacing as
|
||||
//! `SQLITE_BUSY`.
|
||||
//! 2. **All access behind a thin repository layer** rather than queries
|
||||
//! scattered through handlers — this is what keeps the Turso/Postgres options
|
||||
//! cheap and localises the serialization in one place.
|
||||
//!
|
||||
//! rusqlite is synchronous, so every call is wrapped in `spawn_blocking`: a
|
||||
//! write that waits on the mutex must never block a Tokio worker thread.
|
||||
|
||||
pub mod repo;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Context;
|
||||
use rusqlite::Connection;
|
||||
|
||||
const SCHEMA: &str = include_str!("schema.sql");
|
||||
|
||||
/// Handle to the database: one serialized writer, plus read connections.
|
||||
///
|
||||
/// Cloning is cheap and shares the same underlying connections.
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
writer: Arc<Mutex<Connection>>,
|
||||
readers: Arc<ReadPool>,
|
||||
}
|
||||
|
||||
struct ReadPool {
|
||||
conns: Mutex<Vec<Connection>>,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl ReadPool {
|
||||
fn acquire(&self) -> anyhow::Result<Connection> {
|
||||
if let Some(c) = self.conns.lock().expect("read pool poisoned").pop() {
|
||||
return Ok(c);
|
||||
}
|
||||
open_conn(&self.path, false)
|
||||
}
|
||||
|
||||
fn release(&self, conn: Connection) {
|
||||
let mut conns = self.conns.lock().expect("read pool poisoned");
|
||||
// Bounded: excess connections are dropped rather than accumulating.
|
||||
if conns.len() < 8 {
|
||||
conns.push(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_conn(path: &str, writer: bool) -> anyhow::Result<Connection> {
|
||||
let conn = Connection::open(path).with_context(|| format!("opening database {path}"))?;
|
||||
|
||||
// WAL gives concurrent readers alongside the single writer, which suits a
|
||||
// read-dominated workload; `synchronous = NORMAL` is safe under WAL, and
|
||||
// `busy_timeout` makes contention wait rather than error (§8).
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "busy_timeout", 5_000)?;
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
if !writer {
|
||||
conn.pragma_update(None, "query_only", true)?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Opens the database, applying the schema. Idempotent — every statement in
|
||||
/// `schema.sql` is `IF NOT EXISTS`.
|
||||
pub fn open(path: &str) -> anyhow::Result<Self> {
|
||||
let writer = open_conn(path, true)?;
|
||||
writer.execute_batch(SCHEMA).context("applying schema")?;
|
||||
|
||||
Ok(Self {
|
||||
writer: Arc::new(Mutex::new(writer)),
|
||||
readers: Arc::new(ReadPool { conns: Mutex::new(Vec::new()), path: path.to_string() }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `f` against the serialized writer connection on a blocking thread.
|
||||
///
|
||||
/// `f` receives a `Transaction`, so a manifest's scene rows go in as one
|
||||
/// transaction rather than one per row (§8), and a failure rolls back.
|
||||
pub async fn write<T, F>(&self, f: F) -> anyhow::Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&rusqlite::Transaction<'_>) -> anyhow::Result<T> + Send + 'static,
|
||||
{
|
||||
let writer = self.writer.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut conn = writer.lock().expect("writer poisoned");
|
||||
let tx = conn.transaction()?;
|
||||
let out = f(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.context("writer task panicked")?
|
||||
}
|
||||
|
||||
/// Runs `f` against a read connection on a blocking thread.
|
||||
pub async fn read<T, F>(&self, f: F) -> anyhow::Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&Connection) -> anyhow::Result<T> + Send + 'static,
|
||||
{
|
||||
let readers = self.readers.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = readers.acquire()?;
|
||||
let out = f(&conn);
|
||||
readers.release(conn);
|
||||
out
|
||||
})
|
||||
.await
|
||||
.context("reader task panicked")?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_applies_and_roundtrips() {
|
||||
let db = Db::open(":memory:").unwrap();
|
||||
// In-memory databases are per-connection, so only exercise the writer.
|
||||
let n = db
|
||||
.write(|tx| {
|
||||
tx.execute(
|
||||
"INSERT INTO contributors (id, token_hash, created_at) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params!["c1", "hash", "2026-01-01T00:00:00Z"],
|
||||
)?;
|
||||
Ok(tx.query_row("SELECT COUNT(*) FROM contributors", [], |r| r.get::<_, i64>(0))?)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rolls_back_on_error() {
|
||||
let db = Db::open(":memory:").unwrap();
|
||||
let res: anyhow::Result<()> = db
|
||||
.write(|tx| {
|
||||
tx.execute(
|
||||
"INSERT INTO contributors (id, token_hash, created_at) VALUES ('c1','h','t')",
|
||||
[],
|
||||
)?;
|
||||
anyhow::bail!("deliberate failure")
|
||||
})
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
let n = db
|
||||
.write(|tx| {
|
||||
Ok(tx.query_row("SELECT COUNT(*) FROM contributors", [], |r| r.get::<_, i64>(0))?)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "failed transaction must not persist rows");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user