feat(backend): expose freshness for UserDraft staleness check

Variable
- Add `edited_at TIMESTAMPTZ NOT NULL DEFAULT now()` + `edited_by VARCHAR(50)` to the `variable` table (parity with `resource`); set them on INSERT and on every UPDATE.
- Surface them on `ListableVariable` so `getVariable` / `listVariable` return them.

DB drafts (script, flow, app/raw_app)
- The `*WithDraft` endpoints now also return `draft.created_at` as `draft_created_at`. The draft value alone wasn't enough to tell whether a teammate (or another tab) had pushed a fresh draft while local autosave was in flight; the new field is the staleness signal.
- Wired in `get_script_by_path_w_draft` (`ScriptWDraft.draft_created_at`, including the `prefetch_cached` forwarding), `get_flow_by_path_w_draft` (`FlowWDraft.draft_created_at`), and `get_app_w_draft` (`AppWithLastVersionAndDraft.draft_created_at`). OpenAPI updated to match.

The frontend will read these in a follow-up to implement the local-draft staleness check; this commit only widens the API surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-12 22:23:45 +02:00
parent 1df77a076b
commit d844566421
10 changed files with 123 additions and 31 deletions
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Int4",
"Bool",
"Timestamptz",
"TextArray",
"Varchar"
]
},
"nullable": []
},
"hash": "295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Text",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537"
}
@@ -0,0 +1,3 @@
ALTER TABLE variable
DROP COLUMN IF EXISTS edited_by,
DROP COLUMN IF EXISTS edited_at;
@@ -0,0 +1,7 @@
-- Add `edited_at` and `edited_by` so the UI can detect when a variable has
-- been modified remotely while a local autosave was in flight (see the
-- UserDraft staleness check). Mirrors what `resource` already has.
ALTER TABLE variable
ADD COLUMN edited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
ADD COLUMN edited_by VARCHAR(50);
+6
View File
@@ -1480,6 +1480,11 @@ pub struct FlowWDraft {
pub extra_perms: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
/// Timestamp at which the most recent DB draft was created. Used by the
/// frontend's UserDraft staleness check to detect that a teammate (or
/// another tab) pushed a new draft while local autosave was in flight.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1516,6 +1521,7 @@ async fn get_flow_by_path_w_draft(
flow.ws_error_handler_muted,
flow.dedicated_worker,
draft.value AS draft,
draft.created_at AS draft_created_at,
flow.tag,
flow.visible_to_runner_only,
flow.on_behalf_of_email,
+15 -13
View File
@@ -93,6 +93,11 @@ pub struct ScriptWDraft<SR> {
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
/// Timestamp at which the most recent DB draft was created. Used by the
/// frontend's UserDraft staleness check to detect that a teammate (or
/// another tab) pushed a new draft while local autosave was in flight.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
pub schema: Option<Schema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
@@ -170,6 +175,7 @@ impl ScriptWDraft<ScriptRunnableSettingsHandle> {
kind: self.kind,
tag: self.tag,
draft: self.draft,
draft_created_at: self.draft_created_at,
schema: self.schema,
draft_only: self.draft_only,
envs: self.envs,
@@ -978,12 +984,10 @@ async fn create_script_internal<'c>(
.fetch_one(&mut *tx)
.await?;
}
let clashing_script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
&format!(
"SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2",
windmill_common::scripts::SCRIPT_COLUMNS,
),
)
let clashing_script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(&format!(
"SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2",
windmill_common::scripts::SCRIPT_COLUMNS,
))
.bind(&ns.path)
.bind(&w_id)
.fetch_optional(&mut *tx)
@@ -1805,7 +1809,7 @@ async fn get_script_by_path_w_draft(
let mut tx = user_db.begin(&authed).await?;
let script_o = sqlx::query_as::<_, ScriptWDraft<ScriptRunnableSettingsHandle>>(
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script'
WHERE script.path = $1 AND script.workspace_id = $2
ORDER BY script.created_at DESC LIMIT 1",
@@ -2282,12 +2286,10 @@ async fn get_script_by_hash_internal<'c>(
.fetch_optional(&mut **db)
.await?
} else {
sqlx::query_as::<_, ScriptWithStarred<ScriptRunnableSettingsHandle>>(
&format!(
"SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2",
windmill_common::scripts::SCRIPT_COLUMNS,
),
)
sqlx::query_as::<_, ScriptWithStarred<ScriptRunnableSettingsHandle>>(&format!(
"SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2",
windmill_common::scripts::SCRIPT_COLUMNS,
))
.bind(hash)
.bind(workspace_id)
.fetch_optional(&mut **db)
+17
View File
@@ -9541,6 +9541,10 @@ paths:
properties:
draft:
$ref: "#/components/schemas/Flow"
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
/w/{workspace}/flows/exists/{path}:
get:
@@ -21679,6 +21683,10 @@ components:
properties:
draft:
$ref: "#/components/schemas/NewScript"
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
hash:
type: string
required:
@@ -22737,6 +22745,11 @@ components:
type: string
ws_specific:
type: boolean
edited_at:
type: string
format: date-time
edited_by:
type: string
required:
- workspace_id
- path
@@ -26731,6 +26744,10 @@ components:
draft_only:
type: boolean
draft: {}
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
AppHistory:
type: object
+19 -13
View File
@@ -217,6 +217,11 @@ pub struct AppWithLastVersionAndDraft {
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
/// Timestamp at which the most recent DB draft was created. Used by the
/// frontend's UserDraft staleness check to detect that a teammate (or
/// another tab) pushed a new draft while local autosave was in flight.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
@@ -642,29 +647,30 @@ async fn get_app_w_draft(
let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>(
r#"
SELECT
app.id,
app.path,
app.summary,
app.versions,
app.policy,
SELECT
app.id,
app.path,
app.summary,
app.versions,
app.policy,
app.custom_path,
app.extra_perms,
app.extra_perms,
app_version.value,
app_version.created_at,
app_version.created_at,
app_version.created_by,
app.draft_only,
draft.value AS "draft",
draft.created_at AS "draft_created_at",
app_version.raw_app,
app.labels
FROM app
INNER JOIN app_version
INNER JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
LEFT JOIN draft
ON app.path = draft.path
AND draft.workspace_id = $2
LEFT JOIN draft
ON app.path = draft.path
AND draft.workspace_id = $2
AND draft.typ = 'app'
WHERE app.path = $1
WHERE app.path = $1
AND app.workspace_id = $2
"#,
)
+4
View File
@@ -50,6 +50,10 @@ pub struct ListableVariable {
pub labels: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_specific: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edited_at: Option<chrono::DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edited_by: Option<String>,
}
#[derive(Serialize, Deserialize, sqlx::FromRow)]
+12 -5
View File
@@ -134,6 +134,8 @@ async fn list_variables(
"variable.expires_at",
"variable.labels",
"ws_specific.path IS NOT NULL as ws_specific",
"variable.edited_at",
"variable.edited_by",
])
.left()
.join("account")
@@ -216,6 +218,7 @@ async fn get_variable(
"SELECT variable.workspace_id, variable.path, variable.value, variable.is_secret,
variable.description, variable.extra_perms, variable.account, variable.is_oauth,
variable.expires_at, variable.labels,
variable.edited_at, variable.edited_by,
(now() > account.expires_at) as is_expired, account.refresh_error,
resource.path IS NOT NULL as is_linked,
account.refresh_token != '' as is_refreshed,
@@ -441,8 +444,8 @@ async fn create_variable(
sqlx::query!(
"INSERT INTO variable
(workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
(workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
&w_id,
variable.path,
value,
@@ -451,7 +454,8 @@ async fn create_variable(
variable.account,
variable.is_oauth.unwrap_or(false),
variable.expires_at,
variable.labels.as_deref() as Option<&[String]>
variable.labels.as_deref() as Option<&[String]>,
&authed.username
)
.execute(&mut *tx)
.await?;
@@ -1048,6 +1052,8 @@ async fn update_variable(
}
let npath = if has_sql_updates {
sqlb.set("edited_at", "now()");
sqlb.set_str("edited_by", &authed.username);
sqlb.returning("path");
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut *tx).await?;
@@ -1078,10 +1084,11 @@ async fn update_variable(
if let Some(nlabels) = &ns.labels {
sqlx::query!(
"UPDATE variable SET labels = $1 WHERE path = $2 AND workspace_id = $3",
"UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3",
nlabels as &[String],
&npath,
&w_id
&w_id,
&authed.username
)
.execute(&mut *tx)
.await?;