P1b: SQLite project, content-addressed assets, autosave and undo
This commit is contained in:
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user