diff --git a/crates/yaak-models/migrations/20260905000000_model-versions.sql b/crates/yaak-models/migrations/20260905000000_model-versions.sql new file mode 100644 index 00000000..48e8b941 --- /dev/null +++ b/crates/yaak-models/migrations/20260905000000_model-versions.sql @@ -0,0 +1,20 @@ +CREATE TABLE model_versions +( + id TEXT NOT NULL PRIMARY KEY, + model TEXT DEFAULT 'model_version' NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + workspace_id TEXT NOT NULL, + model_type TEXT NOT NULL, + model_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + document TEXT NOT NULL, + reason TEXT NOT NULL +); + +-- Content addressing, enforced by the database rather than by every caller. +CREATE UNIQUE INDEX model_versions_content ON model_versions (model_id, content_hash); + +ALTER TABLE http_responses ADD COLUMN version_id TEXT; +ALTER TABLE grpc_connections ADD COLUMN version_id TEXT; +ALTER TABLE websocket_connections ADD COLUMN version_id TEXT; diff --git a/crates/yaak-models/src/client_db.rs b/crates/yaak-models/src/client_db.rs index e4023e42..8dc0726b 100644 --- a/crates/yaak-models/src/client_db.rs +++ b/crates/yaak-models/src/client_db.rs @@ -118,6 +118,20 @@ impl<'a> ClientDb<'a> { Ok(m.clone()) } + /// Upsert a model WITHOUT recording a model change or emitting an event. + /// + /// Only for rows that are nobody's business but this process's — model + /// versions, whose whole point is that they are local history. Anything the + /// frontend, sync or another window should learn about goes through + /// [`Self::upsert`]. + pub(crate) fn upsert_untracked(&self, model: &M) -> Result + where + M: UpsertModelInfo + Clone, + { + let (m, _created) = self.ctx.upsert(model, &UpdateSource::Background.to_db())?; + Ok(m) + } + fn record_model_change(&self, payload: &ModelPayload) -> Result<()> { let payload_json = serde_json::to_string(payload)?; let source_json = serde_json::to_string(&payload.update_source)?; diff --git a/crates/yaak-models/src/lib.rs b/crates/yaak-models/src/lib.rs index ee6c7929..063ff728 100644 --- a/crates/yaak-models/src/lib.rs +++ b/crates/yaak-models/src/lib.rs @@ -21,6 +21,7 @@ pub mod queries; pub mod query_manager; pub mod render; pub mod util; +pub mod versions; /// Per-connection setup, applied by every pool on every connection it opens. fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> { diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index ae335efc..1ecf0309 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -1539,6 +1539,8 @@ pub struct WebsocketConnection { pub state: WebsocketConnectionState, pub status: i32, pub url: String, + /// The request version this connection was opened from, when one was captured. + pub version_id: Option, } impl UpsertModelInfo for WebsocketConnection { @@ -1578,6 +1580,7 @@ impl UpsertModelInfo for WebsocketConnection { (State, serde_json::to_value(&self.state)?.as_str().into()), (Status, self.status.into()), (Url, self.url.into()), + (VersionId, self.version_id.into()), ]) } @@ -1590,6 +1593,7 @@ impl UpsertModelInfo for WebsocketConnection { WebsocketConnectionIden::State, WebsocketConnectionIden::Status, WebsocketConnectionIden::Url, + WebsocketConnectionIden::VersionId, ] } @@ -1612,6 +1616,7 @@ impl UpsertModelInfo for WebsocketConnection { error: row.get("error")?, state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), status: row.get("status")?, + version_id: row.get("version_id").unwrap_or_default(), }) } } @@ -1965,6 +1970,8 @@ pub struct HttpResponse { pub state: HttpResponseState, pub url: String, pub version: Option, + /// The request version this response was sent from, when one was captured. + pub version_id: Option, } impl UpsertModelInfo for HttpResponse { @@ -2014,6 +2021,7 @@ impl UpsertModelInfo for HttpResponse { (Url, self.url.into()), (Version, self.version.into()), (RequestContentLength, self.request_content_length.into()), + (VersionId, self.version_id.into()), ]) } @@ -2036,6 +2044,7 @@ impl UpsertModelInfo for HttpResponse { HttpResponseIden::StatusReason, HttpResponseIden::Url, HttpResponseIden::Version, + HttpResponseIden::VersionId, ] } @@ -2071,6 +2080,7 @@ impl UpsertModelInfo for HttpResponse { r.get::<_, String>("request_headers").unwrap_or_default().as_str(), ) .unwrap_or_default(), + version_id: r.get("version_id").unwrap_or_default(), }) } } @@ -2516,6 +2526,8 @@ pub struct GrpcConnection { pub state: GrpcConnectionState, pub trailers: BTreeMap, pub url: String, + /// The request version this connection was opened from, when one was captured. + pub version_id: Option, } impl UpsertModelInfo for GrpcConnection { @@ -2557,6 +2569,7 @@ impl UpsertModelInfo for GrpcConnection { (Error, self.error.as_ref().map(|s| s.as_str()).into()), (Trailers, serde_json::to_string(&self.trailers)?.into()), (Url, self.url.into()), + (VersionId, self.version_id.into()), ]) } @@ -2571,6 +2584,7 @@ impl UpsertModelInfo for GrpcConnection { GrpcConnectionIden::Error, GrpcConnectionIden::Trailers, GrpcConnectionIden::Url, + GrpcConnectionIden::VersionId, ] } @@ -2595,6 +2609,7 @@ impl UpsertModelInfo for GrpcConnection { url: row.get("url")?, error: row.get("error")?, trailers: serde_json::from_str(trailers.as_str()).unwrap_or_default(), + version_id: row.get("version_id").unwrap_or_default(), }) } } @@ -3139,6 +3154,137 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource { } } +/// Why a version was captured. Not a UI label — the frontend decides how to +/// phrase these — but it is what makes a history readable when debugging. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_models.ts")] +pub enum ModelVersionReason { + Send, + Switch, + Idle, + Restore, + /// Reserved: an explicit "save a version now" action, which has no UI yet. + Manual, +} + +impl Default for ModelVersionReason { + fn default() -> Self { + Self::Manual + } +} + +/// A point-in-time copy of one request's editable content. +/// +/// Versions are content-addressed: `content_hash` covers exactly what +/// `document` holds, and `(model_id, content_hash)` is unique, so capturing the +/// same content twice returns the row that already exists. That is what lets +/// every send snapshot unconditionally without growing the table. +/// +/// Deliberately absent from [`AnyModel`]: versions are local history. They are +/// not synced, not exported, and not mirrored into the frontend's model store — +/// the frontend asks for the one version it needs to show. +impl Default for ModelVersion { + fn default() -> Self { + Self { + model: "model_version".to_string(), + id: String::new(), + created_at: NaiveDateTime::default(), + updated_at: NaiveDateTime::default(), + workspace_id: String::new(), + model_type: String::new(), + model_id: String::new(), + content_hash: String::new(), + document: Value::Object(Default::default()), + reason: ModelVersionReason::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +#[enum_def(table_name = "model_versions")] +pub struct ModelVersion { + #[ts(type = "\"model_version\"")] + pub model: String, + pub id: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub workspace_id: String, + + /// The `model` field of the versioned model, eg. `http_request`. + pub model_type: String, + pub model_id: String, + pub content_hash: String, + #[ts(type = "Record")] + pub document: Value, + pub reason: ModelVersionReason, +} + +impl UpsertModelInfo for ModelVersion { + fn table_name() -> impl IntoTableRef + IntoIden { + ModelVersionIden::Table + } + + fn id_column() -> impl IntoIden + Eq + Clone { + ModelVersionIden::Id + } + + fn generate_id() -> String { + generate_prefixed_id("mv") + } + + fn order_by() -> (impl IntoColumnRef, Order) { + (ModelVersionIden::CreatedAt, Desc) + } + + fn get_id(&self) -> String { + self.id.clone() + } + + fn insert_values( + self, + source: &UpdateSource, + ) -> DbResult)>> { + use ModelVersionIden::*; + Ok(vec![ + (CreatedAt, upsert_date(source, self.created_at)), + (UpdatedAt, upsert_date(source, self.updated_at)), + (WorkspaceId, self.workspace_id.into()), + (ModelType, self.model_type.into()), + (ModelId, self.model_id.into()), + (ContentHash, self.content_hash.into()), + (Document, serde_json::to_string(&self.document)?.into()), + (Reason, serde_json::to_value(self.reason)?.as_str().into()), + ]) + } + + fn update_columns() -> Vec { + vec![ModelVersionIden::UpdatedAt] + } + + fn from_row(row: &Row) -> rusqlite::Result + where + Self: Sized, + { + let document: String = row.get("document")?; + let reason: String = row.get("reason")?; + Ok(Self { + id: row.get("id")?, + model: row.get("model")?, + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, + workspace_id: row.get("workspace_id")?, + model_type: row.get("model_type")?, + model_id: row.get("model_id")?, + content_hash: row.get("content_hash")?, + document: serde_json::from_str(&document).unwrap_or_default(), + reason: serde_json::from_str(format!(r#""{reason}""#).as_str()).unwrap_or_default(), + }) + } +} + /// Only used as a `from_row` fallback for an unparseable settings column. The /// value a *new* model gets comes from that model's `Default` impl. fn default_request_message_size_setting() -> InheritedIntSetting { diff --git a/crates/yaak-models/src/queries/any_request.rs b/crates/yaak-models/src/queries/any_request.rs index bff17b5e..aed33f2d 100644 --- a/crates/yaak-models/src/queries/any_request.rs +++ b/crates/yaak-models/src/queries/any_request.rs @@ -1,6 +1,7 @@ use crate::client_db::ClientDb; use crate::error::Result; use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest}; +use serde_json::Value; pub enum AnyRequest { HttpRequest(HttpRequest), @@ -8,6 +9,36 @@ pub enum AnyRequest { WebsocketRequest(WebsocketRequest), } +/// Run an expression against whichever request this is, bound as `$request`. +macro_rules! with_request { + ($self:expr, |$request:ident| $body:expr) => { + match $self { + AnyRequest::HttpRequest($request) => $body, + AnyRequest::GrpcRequest($request) => $body, + AnyRequest::WebsocketRequest($request) => $body, + } + }; +} + +impl AnyRequest { + pub fn id(&self) -> &str { + with_request!(self, |request| &request.id) + } + + pub fn workspace_id(&self) -> &str { + with_request!(self, |request| &request.workspace_id) + } + + /// The model name, eg. `http_request`. + pub fn model_type(&self) -> &str { + with_request!(self, |request| &request.model) + } + + pub fn to_value(&self) -> Result { + Ok(with_request!(self, |request| serde_json::to_value(request)?)) + } +} + impl<'a> ClientDb<'a> { pub fn get_any_request(&self, id: &str) -> Result { if let Ok(http_request) = self.get_http_request(id) { diff --git a/crates/yaak-models/src/queries/grpc_requests.rs b/crates/yaak-models/src/queries/grpc_requests.rs index 593e128f..8e8eaf20 100644 --- a/crates/yaak-models/src/queries/grpc_requests.rs +++ b/crates/yaak-models/src/queries/grpc_requests.rs @@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> { source: &UpdateSource, ) -> Result { self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?; + self.delete_model_versions_for_model(m.id.as_str())?; self.delete(m, source) } diff --git a/crates/yaak-models/src/queries/http_requests.rs b/crates/yaak-models/src/queries/http_requests.rs index 18fe9171..f41b5bf9 100644 --- a/crates/yaak-models/src/queries/http_requests.rs +++ b/crates/yaak-models/src/queries/http_requests.rs @@ -24,6 +24,7 @@ impl<'a> ClientDb<'a> { source: &UpdateSource, ) -> Result { self.delete_all_http_responses_for_request(m.id.as_str(), source)?; + self.delete_model_versions_for_model(m.id.as_str())?; self.delete(m, source) } diff --git a/crates/yaak-models/src/queries/mod.rs b/crates/yaak-models/src/queries/mod.rs index e28df092..729c68f8 100644 --- a/crates/yaak-models/src/queries/mod.rs +++ b/crates/yaak-models/src/queries/mod.rs @@ -15,6 +15,7 @@ mod import_source_resources; mod import_sources; mod key_values; mod model_changes; +mod model_versions; mod plugin_key_values; mod plugins; mod settings; diff --git a/crates/yaak-models/src/queries/model_versions.rs b/crates/yaak-models/src/queries/model_versions.rs new file mode 100644 index 00000000..984213a6 --- /dev/null +++ b/crates/yaak-models/src/queries/model_versions.rs @@ -0,0 +1,425 @@ +use crate::client_db::ClientDb; +use crate::error::Result; +use crate::models::{ + GrpcRequest, HttpRequest, ModelVersion, ModelVersionIden, ModelVersionReason, UpsertModelInfo, + WebsocketRequest, +}; +use crate::queries::any_request::AnyRequest; +use crate::util::UpdateSource; +use crate::versions::{apply_version_document, content_hash, version_document}; +use sea_query::{Expr, ExprTrait, Query, SqliteQueryBuilder}; +use sea_query_rusqlite::RusqliteBinder; + +/// Unreferenced versions older than this are dropped. +const RETENTION_DAYS: i64 = 30; + +/// How many unreferenced versions a request keeps, newest first. +const RETENTION_COUNT: i64 = 50; + +impl<'a> ClientDb<'a> { + pub fn get_model_version(&self, id: &str) -> Result { + self.find_one(ModelVersionIden::Id, id) + } + + /// Every version of one model, newest first. + pub fn list_model_versions(&self, model_id: &str) -> Result> { + self.find_many(ModelVersionIden::ModelId, model_id, None) + } + + /// Capture a request's current content, or return the version that already + /// holds it. + /// + /// The single entry point for creating versions. Callers do not check + /// whether anything changed first — that is what content addressing is for, + /// and it is why a send, a window blur and an idle timer can all call this + /// on the same unedited request and leave one row behind. + pub fn snapshot_request( + &self, + request: &AnyRequest, + reason: ModelVersionReason, + ) -> Result { + let document = version_document(&request.to_value()?)?; + let content_hash = content_hash(&document)?; + + if let Some(existing) = self.find_version_by_hash(request.id(), &content_hash) { + return Ok(existing); + } + + let version = self.upsert_untracked(&ModelVersion { + workspace_id: request.workspace_id().to_string(), + model_type: request.model_type().to_string(), + model_id: request.id().to_string(), + content_hash, + document, + reason, + ..Default::default() + })?; + + self.prune_model_versions(request.id())?; + + Ok(version) + } + + pub fn snapshot_request_by_id( + &self, + request_id: &str, + reason: ModelVersionReason, + ) -> Result { + self.snapshot_request(&self.get_any_request(request_id)?, reason) + } + + /// Write a version's content back over the live request. + /// + /// Anything the live request has picked up since its last version is + /// captured first, so a restore is never the thing that loses an edit. The + /// content being written already has a version — the one being restored — + /// so this leaves no new row behind. + pub fn restore_request_version( + &self, + version_id: &str, + source: &UpdateSource, + ) -> Result { + let version = self.get_model_version(version_id)?; + let live = self.get_any_request(&version.model_id)?; + self.snapshot_request(&live, ModelVersionReason::Restore)?; + + let restored = apply_version_document(&live.to_value()?, &version.document); + Ok(match live { + AnyRequest::HttpRequest(_) => AnyRequest::HttpRequest( + self.upsert_http_request(&serde_json::from_value::(restored)?, source)?, + ), + AnyRequest::GrpcRequest(_) => AnyRequest::GrpcRequest( + self.upsert_grpc_request(&serde_json::from_value::(restored)?, source)?, + ), + AnyRequest::WebsocketRequest(_) => AnyRequest::WebsocketRequest( + self.upsert_websocket_request( + &serde_json::from_value::(restored)?, + source, + )?, + ), + }) + } + + /// Whether a request's content has moved on from a given version. + pub fn request_matches_version(&self, version: &ModelVersion) -> Result { + let live = self.get_any_request(&version.model_id)?; + let hash = content_hash(&version_document(&live.to_value()?)?)?; + Ok(hash == version.content_hash) + } + + pub fn delete_model_versions_for_model(&self, model_id: &str) -> Result { + self.delete_many_untracked::(ModelVersionIden::ModelId, model_id) + } + + /// Drop the versions a request no longer needs. + /// + /// A version referenced by a response outlives retention entirely — the + /// point of the feature is that an old response can still show what sent + /// it. Everything else is history the user has not asked to keep, and + /// survives only while it is both recent and among the newest few. + pub fn prune_model_versions(&self, model_id: &str) -> Result { + let cutoff = format!("-{RETENTION_DAYS} days"); + let sql = r#" + DELETE FROM model_versions + WHERE model_id = ?1 + AND id NOT IN ( + SELECT version_id FROM http_responses WHERE request_id = ?1 AND version_id IS NOT NULL + UNION + SELECT version_id FROM grpc_connections WHERE request_id = ?1 AND version_id IS NOT NULL + UNION + SELECT version_id FROM websocket_connections WHERE request_id = ?1 AND version_id IS NOT NULL + ) + AND ( + created_at < datetime('now', ?2) + OR id NOT IN ( + SELECT id FROM model_versions WHERE model_id = ?1 + ORDER BY created_at DESC, rowid DESC LIMIT ?3 + ) + ) + "#; + Ok(self.conn().execute(sql, rusqlite::params![model_id, cutoff, RETENTION_COUNT])?) + } + + fn find_version_by_hash(&self, model_id: &str, content_hash: &str) -> Option { + let (sql, params) = Query::select() + .from(ModelVersionIden::Table) + .column(sea_query::Asterisk) + .cond_where( + Expr::col(ModelVersionIden::ModelId) + .eq(model_id) + .and(Expr::col(ModelVersionIden::ContentHash).eq(content_hash)), + ) + .build_rusqlite(SqliteQueryBuilder); + let mut stmt = self.conn().prepare(sql.as_str()).ok()?; + stmt.query_row(&*params.as_params(), ModelVersion::from_row).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client_db::ClientDb; + use crate::init_in_memory; + use crate::models::{HttpRequest, HttpResponse, Workspace}; + + fn source() -> UpdateSource { + UpdateSource::Background + } + + fn seed(db: &ClientDb) -> (Workspace, HttpRequest) { + let workspace = db + .upsert_workspace(&Workspace { name: "Versions".to_string(), ..Default::default() }, &source()) + .expect("Failed to upsert workspace"); + let request = db + .upsert_http_request( + &HttpRequest { + workspace_id: workspace.id.clone(), + name: "Original".to_string(), + url: "https://example.com/one".to_string(), + ..Default::default() + }, + &source(), + ) + .expect("Failed to upsert request"); + (workspace, request) + } + + fn snapshot(db: &ClientDb, request_id: &str, reason: ModelVersionReason) -> ModelVersion { + db.snapshot_request_by_id(request_id, reason).expect("Failed to snapshot") + } + + #[test] + fn snapshotting_unchanged_content_reuses_the_same_version() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + let first = snapshot(&db, &request.id, ModelVersionReason::Send); + let second = snapshot(&db, &request.id, ModelVersionReason::Idle); + let third = snapshot(&db, &request.id, ModelVersionReason::Switch); + + assert_eq!(first.id, second.id); + assert_eq!(first.id, third.id); + // The first capture's reason is the one that sticks; a version is its content + assert_eq!(second.reason, ModelVersionReason::Send); + assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1); + } + + #[test] + fn bookkeeping_writes_do_not_mint_a_version() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + let first = snapshot(&db, &request.id, ModelVersionReason::Send); + + let folder = db + .upsert_folder( + &crate::models::Folder { + workspace_id: request.workspace_id.clone(), + ..Default::default() + }, + &source(), + ) + .unwrap(); + db.upsert_http_request( + &HttpRequest { + folder_id: Some(folder.id), + sort_priority: 42.0, + ..db.get_http_request(&request.id).unwrap() + }, + &source(), + ) + .unwrap(); + + assert_eq!(snapshot(&db, &request.id, ModelVersionReason::Idle).id, first.id); + assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1); + } + + #[test] + fn editing_content_mints_a_version() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + snapshot(&db, &request.id, ModelVersionReason::Send); + db.upsert_http_request( + &HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() }, + &source(), + ) + .unwrap(); + snapshot(&db, &request.id, ModelVersionReason::Idle); + + assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 2); + } + + #[test] + fn restoring_writes_the_old_content_back_without_a_new_version() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + let original = snapshot(&db, &request.id, ModelVersionReason::Send); + db.upsert_http_request( + &HttpRequest { + url: "https://example.com/two".to_string(), + name: "Edited".to_string(), + ..request.clone() + }, + &source(), + ) + .unwrap(); + let edited = snapshot(&db, &request.id, ModelVersionReason::Idle); + + db.restore_request_version(&original.id, &source()).expect("Failed to restore"); + + let live = db.get_http_request(&request.id).unwrap(); + assert_eq!(live.url, "https://example.com/one"); + assert_eq!(live.name, "Original"); + assert_eq!(live.id, request.id); + + // The restored content already had a version, and the edit it replaced + // still has its own, so nothing new appears + let versions = db.list_model_versions(&request.id).unwrap(); + assert_eq!(versions.len(), 2); + assert!(versions.iter().any(|v| v.id == original.id)); + assert!(versions.iter().any(|v| v.id == edited.id)); + } + + /// The case restore exists to be safe for: an edit that was never captured. + #[test] + fn restoring_captures_uncaptured_edits_first() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + let original = snapshot(&db, &request.id, ModelVersionReason::Send); + db.upsert_http_request( + &HttpRequest { url: "https://example.com/unsaved".to_string(), ..request.clone() }, + &source(), + ) + .unwrap(); + + db.restore_request_version(&original.id, &source()).expect("Failed to restore"); + + let versions = db.list_model_versions(&request.id).unwrap(); + assert_eq!(versions.len(), 2); + let rescued = versions.iter().find(|v| v.id != original.id).unwrap(); + assert_eq!(rescued.reason, ModelVersionReason::Restore); + assert_eq!(rescued.document.get("url").unwrap(), "https://example.com/unsaved"); + } + + #[test] + fn request_matches_version_tracks_the_live_content() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + let version = snapshot(&db, &request.id, ModelVersionReason::Send); + assert!(db.request_matches_version(&version).unwrap()); + + db.upsert_http_request( + &HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() }, + &source(), + ) + .unwrap(); + assert!(!db.request_matches_version(&version).unwrap()); + } + + /// Write `count` distinct versions by walking the request's URL forward. + fn make_versions(db: &ClientDb, request: &HttpRequest, count: usize) -> Vec { + (0..count) + .map(|i| { + db.upsert_http_request( + &HttpRequest { url: format!("https://example.com/{i}"), ..request.clone() }, + &source(), + ) + .unwrap(); + snapshot(db, &request.id, ModelVersionReason::Idle) + }) + .collect() + } + + #[test] + fn unreferenced_versions_are_pruned_to_the_newest_fifty() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + let versions = make_versions(&db, &request, RETENTION_COUNT as usize + 10); + + let kept = db.list_model_versions(&request.id).unwrap(); + assert_eq!(kept.len(), RETENTION_COUNT as usize); + // The oldest went first + assert!(!kept.iter().any(|v| v.id == versions[0].id)); + assert!(kept.iter().any(|v| v.id == versions.last().unwrap().id)); + } + + #[test] + fn a_referenced_version_survives_retention() { + let (query_manager, blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (workspace, request) = seed(&db); + + let pinned = snapshot(&db, &request.id, ModelVersionReason::Send); + db.upsert_http_response( + &HttpResponse { + request_id: request.id.clone(), + workspace_id: workspace.id.clone(), + version_id: Some(pinned.id.clone()), + ..Default::default() + }, + &source(), + &blobs, + ) + .unwrap(); + + make_versions(&db, &request, RETENTION_COUNT as usize + 10); + + let kept = db.list_model_versions(&request.id).unwrap(); + assert!( + kept.iter().any(|v| v.id == pinned.id), + "a version a response points at must outlive retention", + ); + } + + #[test] + fn unreferenced_versions_expire_after_thirty_days() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + let old = snapshot(&db, &request.id, ModelVersionReason::Send); + db.conn() + .execute( + "UPDATE model_versions SET created_at = datetime('now', '-31 days') WHERE id = ?1", + rusqlite::params![old.id], + ) + .unwrap(); + + // Any later capture prunes + db.upsert_http_request( + &HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() }, + &source(), + ) + .unwrap(); + let fresh = snapshot(&db, &request.id, ModelVersionReason::Idle); + + let kept = db.list_model_versions(&request.id).unwrap(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].id, fresh.id); + } + + #[test] + fn deleting_a_request_deletes_its_versions() { + let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB"); + let db = query_manager.connect(); + let (_workspace, request) = seed(&db); + + make_versions(&db, &request, 3); + assert!(!db.list_model_versions(&request.id).unwrap().is_empty()); + + db.delete_http_request_by_id(&request.id, &source()).unwrap(); + assert!(db.list_model_versions(&request.id).unwrap().is_empty()); + } +} diff --git a/crates/yaak-models/src/queries/websocket_requests.rs b/crates/yaak-models/src/queries/websocket_requests.rs index 1dcf752f..1cffb948 100644 --- a/crates/yaak-models/src/queries/websocket_requests.rs +++ b/crates/yaak-models/src/queries/websocket_requests.rs @@ -40,6 +40,7 @@ impl<'a> ClientDb<'a> { source: &UpdateSource, ) -> Result { self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?; + self.delete_model_versions_for_model(websocket_request.id.as_str())?; self.delete(websocket_request, source) } diff --git a/crates/yaak-models/src/queries/workspaces.rs b/crates/yaak-models/src/queries/workspaces.rs index 3b9934a1..be7041ac 100644 --- a/crates/yaak-models/src/queries/workspaces.rs +++ b/crates/yaak-models/src/queries/workspaces.rs @@ -7,7 +7,7 @@ use crate::models::{ GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent, GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden, HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource, - ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, + ImportSourceIden, ModelVersion, ModelVersionIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, WebsocketConnection, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden, @@ -90,6 +90,7 @@ impl<'a> ClientDb<'a> { self.delete_import_source_resources(&import_source.id)?; } self.delete_many_untracked::(ImportSourceIden::WorkspaceId, wid)?; + self.delete_many_untracked::(ModelVersionIden::WorkspaceId, wid)?; self.delete_many_untracked::(SyncStateIden::WorkspaceId, wid)?; self.delete_many_untracked::(WorkspaceMetaIden::WorkspaceId, wid)?; self.delete(workspace, source) diff --git a/crates/yaak-models/src/versions.rs b/crates/yaak-models/src/versions.rs new file mode 100644 index 00000000..1864d3fe --- /dev/null +++ b/crates/yaak-models/src/versions.rs @@ -0,0 +1,186 @@ +//! Content addressing for model versions. +//! +//! A version's identity is its *content*, so the two functions here — what +//! counts as content, and how content becomes a hash — are the whole of it. +//! Everything else about versioning (when to capture, what to keep, how to +//! restore) is built on top and stays in `queries::model_versions`. + +use crate::error::Result; +use serde::Serialize; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +/// Keys that describe a model's place in the workspace rather than what the +/// user typed into it. +/// +/// Dropping them is what makes a version stable: moving a request into a +/// folder, dragging it up the sidebar, or simply saving it again all rewrite +/// these and nothing else, and none of them should mint a version or show up +/// in a diff. It is also why one rule covers HTTP, gRPC and WebSocket — the +/// three differ only in the content fields, which are all kept. +const BOOKKEEPING_KEYS: &[&str] = + &["model", "id", "createdAt", "updatedAt", "workspaceId", "folderId", "sortPriority"]; + +/// The editable content of a model, as the object a version stores. +pub fn version_document(model: &T) -> Result { + let mut value = serde_json::to_value(model)?; + if let Some(object) = value.as_object_mut() { + for key in BOOKKEEPING_KEYS { + object.remove(*key); + } + } + Ok(value) +} + +/// The hash a version is addressed by. +/// +/// Canonical by construction: `serde_json::Map` is a `BTreeMap` here, so +/// serialization already visits keys in sorted order and two documents that +/// differ only in key order hash the same. +pub fn content_hash(document: &Value) -> Result { + let canonical = serde_json::to_vec(document)?; + Ok(hex::encode(Sha256::digest(&canonical))) +} + +/// Lay a version's document back over a live model. +/// +/// Keys the document carries win; keys it doesn't mention keep whatever the +/// live model has. That covers both halves of a restore: bookkeeping (id, +/// folder, sort order) survives because the document never held it, and a field +/// added to the model after the version was captured survives because the +/// version predates it. +pub fn apply_version_document(live: &Value, document: &Value) -> Value { + let mut merged = live.as_object().cloned().unwrap_or_else(Map::new); + if let Some(document) = document.as_object() { + for (key, value) in document { + merged.insert(key.clone(), value.clone()); + } + } + Value::Object(merged) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{HttpRequest, HttpRequestHeader}; + use chrono::Utc; + + fn request() -> HttpRequest { + HttpRequest { + id: "rq_1".to_string(), + workspace_id: "wk_1".to_string(), + folder_id: Some("fl_1".to_string()), + name: "Get user".to_string(), + url: "https://example.com/users/1".to_string(), + method: "GET".to_string(), + sort_priority: 1.0, + headers: vec![HttpRequestHeader { + name: "Accept".to_string(), + value: "application/json".to_string(), + ..Default::default() + }], + ..Default::default() + } + } + + fn hash_of(request: &HttpRequest) -> String { + content_hash(&version_document(request).unwrap()).unwrap() + } + + #[test] + fn document_holds_content_and_drops_bookkeeping() { + let document = version_document(&request()).unwrap(); + let object = document.as_object().unwrap(); + + for key in BOOKKEEPING_KEYS { + assert!(!object.contains_key(*key), "document should not carry {key}"); + } + + assert_eq!(object.get("url").unwrap(), "https://example.com/users/1"); + assert_eq!(object.get("name").unwrap(), "Get user"); + assert_eq!(object.get("method").unwrap(), "GET"); + assert!(object.contains_key("headers")); + assert!(object.contains_key("body")); + assert!(object.contains_key("authentication")); + assert!(object.contains_key("description")); + assert!(object.contains_key("settingFollowRedirects")); + } + + #[test] + fn bookkeeping_never_changes_the_hash() { + let base = hash_of(&request()); + + let moved = HttpRequest { folder_id: Some("fl_2".to_string()), ..request() }; + assert_eq!(hash_of(&moved), base, "folder"); + + let resorted = HttpRequest { sort_priority: 99.5, ..request() }; + assert_eq!(hash_of(&resorted), base, "sort priority"); + + let touched = + HttpRequest { updated_at: Utc::now().naive_utc(), created_at: Utc::now().naive_utc(), ..request() }; + assert_eq!(hash_of(&touched), base, "timestamps"); + + let renamed_id = HttpRequest { id: "rq_2".to_string(), ..request() }; + assert_eq!(hash_of(&renamed_id), base, "id"); + + let moved_workspace = HttpRequest { workspace_id: "wk_2".to_string(), ..request() }; + assert_eq!(hash_of(&moved_workspace), base, "workspace"); + } + + #[test] + fn editable_content_changes_the_hash() { + let base = hash_of(&request()); + + assert_ne!(hash_of(&HttpRequest { url: "https://example.com/users/2".into(), ..request() }), base); + assert_ne!(hash_of(&HttpRequest { method: "POST".into(), ..request() }), base); + assert_ne!(hash_of(&HttpRequest { name: "Get other user".into(), ..request() }), base); + assert_ne!(hash_of(&HttpRequest { description: "Notes".into(), ..request() }), base); + assert_ne!(hash_of(&HttpRequest { headers: vec![], ..request() }), base); + assert_ne!( + hash_of(&HttpRequest { body_type: Some("application/json".into()), ..request() }), + base + ); + } + + /// The hash has to survive being written to and read back from the + /// database, which does not preserve key order. + #[test] + fn key_order_does_not_change_the_hash() { + let a: Value = serde_json::from_str(r#"{"url":"a","method":"GET"}"#).unwrap(); + let b: Value = serde_json::from_str(r#"{"method":"GET","url":"a"}"#).unwrap(); + assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap()); + } + + #[test] + fn applying_a_document_keeps_the_live_model_identity() { + let live = serde_json::to_value(request()).unwrap(); + let document = version_document(&HttpRequest { + url: "https://example.com/users/2".to_string(), + ..request() + }) + .unwrap(); + + let merged = apply_version_document(&live, &document); + let object = merged.as_object().unwrap(); + + assert_eq!(object.get("url").unwrap(), "https://example.com/users/2"); + assert_eq!(object.get("id").unwrap(), "rq_1"); + assert_eq!(object.get("folderId").unwrap(), "fl_1"); + assert_eq!(object.get("sortPriority").unwrap(), 1.0); + assert_eq!(object.get("model").unwrap(), "http_request"); + } + + /// A version captured before a field existed must not blank that field out. + #[test] + fn applying_an_older_document_leaves_unknown_fields_alone() { + let live = serde_json::to_value(request()).unwrap(); + let document = serde_json::json!({ "url": "https://example.com/old" }); + + let merged = apply_version_document(&live, &document); + let object = merged.as_object().unwrap(); + + assert_eq!(object.get("url").unwrap(), "https://example.com/old"); + assert_eq!(object.get("method").unwrap(), "GET"); + assert_eq!(object.get("name").unwrap(), "Get user"); + } +}