mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
c09de594b6
* feat: version resource values with history, diff and restore Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: record resource versions in a trigger so direct writes are covered Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: show the selected version's value and tighten history write access Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: gate resource version recording in trigger WHEN clauses Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: clear a resource's past versions, and address review nits Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: restore the displayed version and keep author attribution on pooled writes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: scope history to the selected workspace and gate clearing on ownership Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: gate restore on write access and clearing on the signed-in workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(frontend): share the version-history row between script and resource drawers Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: trim resource version history in the monitor sweep, not on write Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(frontend): match the script versions drawer shell for resource history Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(frontend): highlight version values instead of mounting monaco Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(frontend): match the script drawer's code preview presentation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: rank version trim in one windowed pass instead of a correlated delete Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(frontend): treat the newest version as current by position Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf: gate the resource version trim to an hourly sweep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: unnest the version row action and correct the trim cadence docs * perf: cap the history listing and use sets for reference lookup * feat: warn when a resource is written more than 60 times a minute * fix: lower the resource write advisory to 20 per minute * fix: discard stale history loads and never diff against an unread value * fix: correct the write advisory boundary and document the eviction lock * fix: read history and the live value from one snapshot * refactor: read the drawer's diff baseline from versions, not the live resource * fix: open the history drawer with no version selected * fix: disarm the clear confirmation and clear the pane when the selection moves * fix: explain the missing diff and drop a guard that can no longer fire --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
7.7 KiB
Rust
216 lines
7.7 KiB
Rust
//! Regression tests for resource value history.
|
|
//!
|
|
//! Four properties that a later refactor could plausibly undo, each with a cost
|
|
//! that is invisible until it bites:
|
|
//! - `state` and `cache` resources are excluded. They are rewritten by every job
|
|
//! that calls `setState` or caches a result, so versioning them would grow the
|
|
//! table without bound and without anyone asking for it.
|
|
//! - an unchanged value mints nothing, which is what keeps no-op saves, renames and
|
|
//! description edits out of the history.
|
|
//! - restore appends the old value as a new version rather than rewinding, so the
|
|
//! history stays append-only and the restore is itself attributable.
|
|
//! - writes that never touch the resource handlers are still recorded. Variable
|
|
//! renames, workspace forks and native integrations all write `resource` directly,
|
|
//! and a rename that changes path and value in one statement used to leave the
|
|
//! newest version holding the pre-rename value while the UI labelled it "Current".
|
|
//! This is why recording lives in a database trigger rather than the handlers.
|
|
|
|
use serde_json::{json, Value};
|
|
use sqlx::{Pool, Postgres};
|
|
|
|
use windmill_test_utils::*;
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(b: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
b.header("Authorization", "Bearer RVER_ADMIN_TOKEN")
|
|
}
|
|
|
|
async fn history(base: &str, path: &str) -> anyhow::Result<Vec<Value>> {
|
|
let body: Value = authed(client().get(format!("{base}/resources/history/p/{path}")))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
Ok(body["versions"].as_array().cloned().unwrap_or_default())
|
|
}
|
|
|
|
#[sqlx::test(fixtures("resource_versioning"))]
|
|
async fn test_resource_version_history(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let base = format!("http://localhost:{}/api/w/rver-ws", server.addr.port());
|
|
let path = "u/rver-admin/db";
|
|
|
|
let resp = authed(client().post(format!("{base}/resources/create")))
|
|
.json(&json!({
|
|
"path": path,
|
|
"value": {"host": "one"},
|
|
"resource_type": "postgresql"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "create should succeed");
|
|
assert_eq!(history(&base, path).await?.len(), 1, "create mints v1");
|
|
|
|
authed(client().post(format!("{base}/resources/update_value/{path}")))
|
|
.json(&json!({"value": {"host": "two"}}))
|
|
.send()
|
|
.await?;
|
|
let versions = history(&base, path).await?;
|
|
assert_eq!(versions.len(), 2, "a changed value mints a version");
|
|
|
|
authed(client().post(format!("{base}/resources/update_value/{path}")))
|
|
.json(&json!({"value": {"host": "two"}}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
history(&base, path).await?.len(),
|
|
2,
|
|
"re-saving an identical value must not mint a version"
|
|
);
|
|
|
|
// Restore the oldest version: the value goes back, the history grows.
|
|
let oldest = versions.last().unwrap()["id"].as_i64().unwrap();
|
|
let resp = authed(client().post(format!("{base}/resources/history/restore/v/{oldest}")))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "restore should succeed");
|
|
|
|
let restored: Value = authed(client().get(format!("{base}/resources/get_value/{path}")))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
assert_eq!(restored["host"], "one", "restore brings the old value back");
|
|
assert_eq!(
|
|
history(&base, path).await?.len(),
|
|
3,
|
|
"restore appends rather than rewinding"
|
|
);
|
|
|
|
// Machine-written resource types stay out of the history entirely.
|
|
for internal in ["state", "cache"] {
|
|
let internal_path = format!("u/rver-admin/{internal}_item");
|
|
authed(client().post(format!("{base}/resources/create")))
|
|
.json(&json!({
|
|
"path": internal_path,
|
|
"value": {"a": 1},
|
|
"resource_type": internal
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
history(&base, &internal_path).await?.len(),
|
|
0,
|
|
"{internal} resources must not be versioned"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A version's reported dangling references. Pins that `$jsonvar:` is matched on its own prefix
|
|
/// rather than colliding with `$var:` — the two share a suffix, so a check written with a
|
|
/// substring test instead of a prefix test would report every `$jsonvar:` as missing.
|
|
#[sqlx::test(fixtures("resource_versioning"))]
|
|
async fn test_missing_references(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let base = format!("http://localhost:{}/api/w/rver-ws", server.addr.port());
|
|
let path = "u/rver-admin/refs";
|
|
|
|
authed(client().post(format!("{base}/variables/create")))
|
|
.json(&json!({
|
|
"path": "u/rver-admin/present",
|
|
"value": "v",
|
|
"is_secret": false,
|
|
"description": ""
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
authed(client().post(format!("{base}/resources/create")))
|
|
.json(&json!({
|
|
"path": path,
|
|
"value": {
|
|
"a": "$jsonvar:u/rver-admin/present",
|
|
"b": "$jsonvar:u/rver-admin/absent",
|
|
"c": "$var:u/rver-admin/present",
|
|
"d": "$res:u/rver-admin/absent"
|
|
},
|
|
"resource_type": "postgresql"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
let id = history(&base, path).await?[0]["id"].as_i64().unwrap();
|
|
let version: Value = authed(client().get(format!("{base}/resources/history/v/{id}")))
|
|
.send()
|
|
.await?
|
|
.json()
|
|
.await?;
|
|
let mut missing: Vec<&str> = version["missing_references"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|v| v.as_str().unwrap())
|
|
.collect();
|
|
missing.sort();
|
|
|
|
assert_eq!(
|
|
missing,
|
|
vec!["$jsonvar:u/rver-admin/absent", "$res:u/rver-admin/absent"],
|
|
"only the references that do not resolve should be reported"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Writes that bypass the resource handlers entirely. A variable rename changes a linked
|
|
/// resource's path and value in one statement; the cascading FK moves the history to the new
|
|
/// path, so without trigger-level recording the newest row would keep the pre-rename value and
|
|
/// disagree with the resource it describes.
|
|
#[sqlx::test(fixtures("resource_versioning"))]
|
|
async fn test_direct_writes_are_recorded(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
let path = "u/rver-admin/direct";
|
|
sqlx::query(
|
|
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by, edited_at)
|
|
VALUES ('rver-ws', $1, '{\"h\":\"one\"}', 'postgresql', 'rver-admin', now())",
|
|
)
|
|
.bind(path)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let renamed = "u/rver-admin/direct_renamed";
|
|
sqlx::query(
|
|
"UPDATE resource SET path = $1, value = '{\"h\":\"two\"}', edited_at = now()
|
|
WHERE workspace_id = 'rver-ws' AND path = $2",
|
|
)
|
|
.bind(renamed)
|
|
.bind(path)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let (count, newest): (i64, Option<String>) = sqlx::query_as(
|
|
"SELECT count(*), (SELECT value->>'h' FROM resource_version
|
|
WHERE workspace_id = 'rver-ws' AND path = $1
|
|
ORDER BY id DESC LIMIT 1)
|
|
FROM resource_version WHERE workspace_id = 'rver-ws' AND path = $1",
|
|
)
|
|
.bind(renamed)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
|
|
assert_eq!(count, 2, "the direct insert and the rename both record");
|
|
assert_eq!(
|
|
newest.as_deref(),
|
|
Some("two"),
|
|
"newest version must match the live value after a rename that also changed it"
|
|
);
|
|
|
|
Ok(())
|
|
}
|