mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-07 10:27:15 +02:00
feat(models): add content-addressed request versions
One table versions HTTP, gRPC and WebSocket requests alike. A version stores the model's editable content — the serialized model minus model/id/timestamps/ workspace/folder/sortPriority — and is addressed by the sha256 of that document, with (model_id, content_hash) unique. Content addressing is what makes the trigger side simple: snapshot_request can be called by a send, a window blur and an idle timer on the same unedited request and leave one row behind, so no caller has to reason about whether anything changed. Dropping bookkeeping keys is what makes it stable — moving a request between folders or re-sorting it rewrites those fields and nothing else, and neither should mint a version or show up in a diff. The same rule covers all three request types because they differ only in content fields. Responses and gRPC/WebSocket connections gain a nullable version_id. Versions are local history, so ModelVersion is deliberately absent from AnyModel: no model-change rows, no frontend store bucket, no sync, no export. Retention keeps anything a response points at, plus the newest 50 per request within 30 days; request and workspace deletes cascade.
This commit is contained in:
@@ -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;
|
||||
@@ -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<M>(&self, model: &M) -> Result<M>
|
||||
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)?;
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// The request version this response was sent from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
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<String, String>,
|
||||
pub url: String,
|
||||
/// The request version this connection was opened from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
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<string, any>")]
|
||||
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<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
||||
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<impl IntoIden> {
|
||||
vec![ModelVersionIden::UpdatedAt]
|
||||
}
|
||||
|
||||
fn from_row(row: &Row) -> rusqlite::Result<Self>
|
||||
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 {
|
||||
|
||||
@@ -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<Value> {
|
||||
Ok(with_request!(self, |request| serde_json::to_value(request)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
|
||||
if let Ok(http_request) = self.get_http_request(id) {
|
||||
|
||||
@@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ModelVersion> {
|
||||
self.find_one(ModelVersionIden::Id, id)
|
||||
}
|
||||
|
||||
/// Every version of one model, newest first.
|
||||
pub fn list_model_versions(&self, model_id: &str) -> Result<Vec<ModelVersion>> {
|
||||
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<ModelVersion> {
|
||||
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<ModelVersion> {
|
||||
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<AnyRequest> {
|
||||
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::<HttpRequest>(restored)?, source)?,
|
||||
),
|
||||
AnyRequest::GrpcRequest(_) => AnyRequest::GrpcRequest(
|
||||
self.upsert_grpc_request(&serde_json::from_value::<GrpcRequest>(restored)?, source)?,
|
||||
),
|
||||
AnyRequest::WebsocketRequest(_) => AnyRequest::WebsocketRequest(
|
||||
self.upsert_websocket_request(
|
||||
&serde_json::from_value::<WebsocketRequest>(restored)?,
|
||||
source,
|
||||
)?,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a request's content has moved on from a given version.
|
||||
pub fn request_matches_version(&self, version: &ModelVersion) -> Result<bool> {
|
||||
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<usize> {
|
||||
self.delete_many_untracked::<ModelVersion>(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<usize> {
|
||||
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<ModelVersion> {
|
||||
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<ModelVersion> {
|
||||
(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());
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<ModelVersion>(ModelVersionIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||
self.delete(workspace, source)
|
||||
|
||||
@@ -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<T: Serialize>(model: &T) -> Result<Value> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user