P1b: SQLite project, content-addressed assets, autosave and undo

This commit is contained in:
Emil
2026-11-01 01:19:55 +03:00
parent 4f4ee05a3a
commit 0acf11ccb0
22 changed files with 1405 additions and 32 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "zhila-project"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
zhila-core = { path = "../zhila-core" }
zhila-protocol = { path = "../zhila-protocol" }
zhila-material = { path = "../zhila-material" }
rusqlite = { version = "0.41", features = ["bundled"] }
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile = "3"
+502
View File
@@ -0,0 +1,502 @@
//! ZHILA project store: SQLite plus content-addressed audio assets.
//!
//! The database is the mutable session; the assets are the source material.
//! On load a core's audio is decoded again, so the faces and peaks cannot drift
//! from the bytes. The project directory is portable: `project.db` + `assets/`.
use std::fs;
use std::path::{Path, PathBuf};
use rusqlite::{Connection, OptionalExtension, params};
use serde_json::Value;
use thiserror::Error;
use zhila_core::{JournalEntry, ProjectData};
use zhila_protocol::{CoreSummary, Pattern, Seal, VEIN_COUNT};
const SCHEMA_VERSION: &str = "1";
#[derive(Debug, Error)]
pub enum ProjectError {
#[error("project filesystem: {0}")]
Io(#[from] std::io::Error),
#[error("project database: {0}")]
Db(#[from] rusqlite::Error),
#[error("project JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("material: {0}")]
Material(String),
}
pub struct LoadedProject {
pub data: ProjectData,
pub warnings: Vec<String>,
}
pub struct ProjectStore {
root: PathBuf,
conn: Connection,
}
pub struct ProjectSnapshot<'a> {
pub name: &'a str,
pub revision: u64,
pub bpm: f32,
pub gains: &'a [f32; VEIN_COUNT],
pub pattern: &'a Pattern,
pub cores: &'a [CoreSummary],
pub journal: &'a [JournalEntry],
}
impl ProjectStore {
pub fn open(root: impl AsRef<Path>) -> Result<Self, ProjectError> {
let root = root.as_ref().to_path_buf();
fs::create_dir_all(root.join("assets"))?;
let conn = Connection::open(root.join("project.db"))?;
conn.busy_timeout(std::time::Duration::from_secs(3))?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
revision INTEGER NOT NULL,
bpm REAL NOT NULL,
gains TEXT NOT NULL,
pattern TEXT NOT NULL,
saved_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cores (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
seal TEXT NOT NULL,
asset TEXT NOT NULL DEFAULT '',
source_name TEXT NOT NULL DEFAULT '',
imported_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS journal (
seq INTEGER PRIMARY KEY,
revision INTEGER NOT NULL,
actor TEXT NOT NULL,
at_ms INTEGER NOT NULL,
command TEXT NOT NULL
);",
)?;
conn.execute(
"INSERT INTO meta (key, value) VALUES ('schema', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![SCHEMA_VERSION],
)?;
Ok(Self { root, conn })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn project_db(&self) -> PathBuf {
self.root.join("project.db")
}
pub fn load(&self) -> Result<Option<LoadedProject>, ProjectError> {
let row = self
.conn
.query_row(
"SELECT name, revision, bpm, gains, pattern FROM project WHERE id = 1",
[],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, f64>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
},
)
.optional()?;
let Some((name, revision, bpm, gains_json, pattern_json)) = row else {
return Ok(None);
};
let gains_value: Vec<f32> = serde_json::from_str(&gains_json)?;
let mut gains = zhila_core::INITIAL_GAINS;
for (slot, value) in gains.iter_mut().zip(gains_value.iter()) {
*slot = *value;
}
let mut pattern: Pattern = serde_json::from_str(&pattern_json)?;
let mut warnings = Vec::new();
let mut cores = Vec::new();
{
let mut statement = self.conn.prepare(
"SELECT id, name, seal, asset, source_name FROM cores ORDER BY imported_at",
)?;
let rows = statement.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
})?;
for row in rows {
let (id, name, seal_text, asset, source_name) = row?;
if asset.is_empty() {
warnings.push(format!("{id}: no asset recorded"));
continue;
}
let path = self.root.join(&asset);
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(error) => {
warnings.push(format!("{id}: {error}"));
continue;
}
};
let seal: Seal = serde_json::from_str(&seal_text).unwrap_or(Seal::Unknown);
match zhila_material::import_bytes(&source_name, seal, bytes) {
Ok(mut core) => {
core.summary.name = name;
if core.summary.id != id {
warnings.push(format!("{id}: content hash is {}", core.summary.id));
}
cores.push(core);
}
Err(error) => warnings.push(format!("{id}: {error}")),
}
}
}
for row in &mut pattern.rows {
let missing = row.face.as_ref().and_then(|face| {
let valid = cores.iter().any(|core| {
core.summary.id == face.core
&& core
.faces
.iter()
.any(|candidate| candidate.index == face.face)
});
(!valid).then(|| format!("pattern {}: face {:?} missing", row.vein.name(), face))
});
if let Some(message) = missing {
row.face = None;
warnings.push(message);
}
}
let mut journal = Vec::new();
{
let mut statement = self
.conn
.prepare("SELECT seq, revision, actor, at_ms, command FROM journal ORDER BY seq")?;
let rows = statement.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, String>(4)?,
))
})?;
for row in rows {
let (seq, revision, actor_text, at_ms, command_text) = row?;
let actor: zhila_protocol::Actor = match serde_json::from_str(&actor_text) {
Ok(actor) => actor,
Err(_) => {
warnings.push(format!("journal {seq}: actor unreadable"));
continue;
}
};
let command: zhila_protocol::Command = match serde_json::from_str(&command_text) {
Ok(command) => command,
Err(_) => {
warnings.push(format!("journal {seq}: command unreadable"));
continue;
}
};
journal.push(JournalEntry {
seq: seq as u64,
revision: revision as u64,
actor,
at_ms: at_ms as u64,
command,
});
}
}
let seq = journal.last().map(|entry| entry.seq).unwrap_or(0);
Ok(Some(LoadedProject {
data: ProjectData {
name,
revision: revision as u64,
bpm: bpm as f32,
gains,
pattern,
cores,
journal,
seq,
},
warnings,
}))
}
pub fn save(&mut self, snapshot: &ProjectSnapshot<'_>) -> Result<(), ProjectError> {
let gains = serde_json::to_string(snapshot.gains)?;
let pattern = serde_json::to_string(snapshot.pattern)?;
let saved_at = now_ms();
let tx = self.conn.transaction()?;
tx.execute(
"INSERT INTO project (id, name, revision, bpm, gains, pattern, saved_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
revision = excluded.revision,
bpm = excluded.bpm,
gains = excluded.gains,
pattern = excluded.pattern,
saved_at = excluded.saved_at",
params![
snapshot.name,
snapshot.revision as i64,
snapshot.bpm as f64,
gains,
pattern,
saved_at as i64
],
)?;
for core in snapshot.cores {
tx.execute(
"INSERT INTO cores (id, name, seal, imported_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
seal = excluded.seal",
params![
core.id,
core.name,
serde_json::to_string(&core.seal)?,
now_ms() as i64
],
)?;
}
tx.execute("DELETE FROM journal", [])?;
for entry in snapshot.journal {
tx.execute(
"INSERT INTO journal (seq, revision, actor, at_ms, command)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
entry.seq as i64,
entry.revision as i64,
serde_json::to_string(&entry.actor)?,
entry.at_ms as i64,
serde_json::to_string(&entry.command)?
],
)?;
}
tx.commit()?;
Ok(())
}
/// Write the encoded source bytes and record the core row. Called once per
/// import; `save` later keeps the name/seal in sync.
pub fn write_asset(
&mut self,
summary: &CoreSummary,
source_name: &str,
bytes: &[u8],
) -> Result<(), ProjectError> {
let extension = Path::new(source_name)
.extension()
.and_then(|value| value.to_str())
.map(|value| {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.take(8)
.collect::<String>()
.to_ascii_lowercase()
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "bin".into());
let asset = format!("assets/{}.{}", summary.id, extension);
let path = self.root.join(&asset);
let temporary = path.with_extension("tmp");
fs::write(&temporary, bytes)?;
fs::rename(&temporary, &path)?;
self.conn.execute(
"INSERT INTO cores (id, name, seal, asset, source_name, imported_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
seal = excluded.seal,
asset = excluded.asset,
source_name = excluded.source_name,
imported_at = excluded.imported_at",
params![
summary.id,
summary.name,
serde_json::to_string(&summary.seal)?,
asset,
source_name,
now_ms() as i64
],
)?;
Ok(())
}
pub fn asset_path(&self, asset: &str) -> Option<PathBuf> {
let relative = Path::new(asset);
if relative.is_absolute()
|| relative.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
return None;
}
let path = self.root.join(relative);
path.starts_with(&self.root).then_some(path)
}
pub fn read_asset(&self, asset: &str) -> Result<Vec<u8>, ProjectError> {
let path = self
.asset_path(asset)
.ok_or_else(|| ProjectError::Material("asset escapes the project".into()))?;
Ok(fs::read(path)?)
}
pub fn project_name(&self) -> Result<Option<String>, ProjectError> {
let name = self
.conn
.query_row("SELECT name FROM project WHERE id = 1", [], |row| {
row.get::<_, String>(0)
})
.optional()?;
Ok(name)
}
pub fn debug_value(&self, sql: &str) -> Result<Value, ProjectError> {
let mut statement = self.conn.prepare(sql)?;
let mut rows = statement.query([])?;
if let Some(row) = rows.next()? {
let value: String = row.get(0)?;
return Ok(serde_json::from_str(&value).unwrap_or(Value::String(value)));
}
Ok(Value::Null)
}
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use zhila_core::Core;
use zhila_protocol::{Actor, Command, FaceRef, PatternRow, Seal};
fn fixture() -> Vec<u8> {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/clicks-01.wav");
std::fs::read(path).expect("fixture")
}
fn pattern_with_face(core: &Core) -> Pattern {
let top = core
.summary
.faces
.iter()
.find(|face| face.rank == 0)
.map(|face| face.index)
.unwrap_or(0);
let mut rows = Vec::new();
for vein in zhila_protocol::VeinId::ALL {
let mut velocities = vec![0u8; 16];
if vein == zhila_protocol::VeinId::Bass {
velocities[0] = 96;
velocities[8] = 72;
}
rows.push(PatternRow {
vein,
face: (vein == zhila_protocol::VeinId::Bass).then(|| FaceRef {
core: core.summary.id.clone(),
face: top,
}),
velocities,
});
}
Pattern {
steps: 16,
steps_per_bar: 8,
rows,
}
}
#[test]
fn saves_and_loads_the_project_with_assets() {
let dir = tempfile::tempdir().unwrap();
let mut store = ProjectStore::open(dir.path()).unwrap();
assert!(store.load().unwrap().is_none());
let bytes = fixture();
let core =
zhila_material::import_bytes("clicks-01.wav", Seal::Found, bytes.clone()).unwrap();
let pattern = pattern_with_face(&core);
let gains = [0.8, 0.7, 0.6, 0.5, 0.4];
let journal = vec![JournalEntry {
seq: 1,
revision: 1,
actor: Actor::Hand,
at_ms: 12,
command: Command::SetTempo { bpm: 132.0 },
}];
store
.write_asset(&core.summary, "clicks-01.wav", &bytes)
.unwrap();
store
.save(&ProjectSnapshot {
name: "clicks project",
revision: 4,
bpm: 132.0,
gains: &gains,
pattern: &pattern,
cores: std::slice::from_ref(&core.summary),
journal: &journal,
})
.unwrap();
drop(store);
let store = ProjectStore::open(dir.path()).unwrap();
let loaded = store.load().unwrap().expect("project");
assert_eq!(loaded.data.name, "clicks project");
assert_eq!(loaded.data.revision, 4);
assert_eq!(loaded.data.bpm, 132.0);
assert_eq!(loaded.data.gains, gains);
assert_eq!(loaded.data.cores.len(), 1);
assert_eq!(loaded.data.cores[0].summary.id, core.summary.id);
assert_eq!(loaded.data.cores[0].faces.len(), 16);
assert_eq!(loaded.data.pattern.rows.len(), 5);
assert_eq!(loaded.data.pattern.rows[0].velocities[0], 96);
assert_eq!(
loaded.data.pattern.rows[0].face.as_ref().unwrap().core,
core.summary.id
);
assert_eq!(loaded.data.journal.len(), 1);
assert_eq!(loaded.data.seq, 1);
assert!(loaded.warnings.is_empty());
assert!(store.asset_path("assets/../project.db").is_none());
}
}