From d8445664216221e1b6340e49ee032a34d989fd91 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 12 May 2026 22:23:45 +0200 Subject: [PATCH] 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) --- ...e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json | 23 +++++++++++++ ...ba3d8bbf1608519b08f2544c64ecfe130c537.json | 17 ++++++++++ ...2200642_add_edited_at_to_variable.down.sql | 3 ++ ...512200642_add_edited_at_to_variable.up.sql | 7 ++++ backend/windmill-api-flows/src/flows.rs | 6 ++++ backend/windmill-api-scripts/src/scripts.rs | 28 ++++++++-------- backend/windmill-api/openapi.yaml | 17 ++++++++++ backend/windmill-api/src/apps.rs | 32 +++++++++++-------- backend/windmill-common/src/variables.rs | 4 +++ backend/windmill-store/src/variables.rs | 17 +++++++--- 10 files changed, 123 insertions(+), 31 deletions(-) create mode 100644 backend/.sqlx/query-295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json create mode 100644 backend/.sqlx/query-5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537.json create mode 100644 backend/migrations/20260512200642_add_edited_at_to_variable.down.sql create mode 100644 backend/migrations/20260512200642_add_edited_at_to_variable.up.sql diff --git a/backend/.sqlx/query-295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json b/backend/.sqlx/query-295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json new file mode 100644 index 0000000000..3e47b7b034 --- /dev/null +++ b/backend/.sqlx/query-295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622.json @@ -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" +} diff --git a/backend/.sqlx/query-5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537.json b/backend/.sqlx/query-5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537.json new file mode 100644 index 0000000000..cd81ba0052 --- /dev/null +++ b/backend/.sqlx/query-5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537.json @@ -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" +} diff --git a/backend/migrations/20260512200642_add_edited_at_to_variable.down.sql b/backend/migrations/20260512200642_add_edited_at_to_variable.down.sql new file mode 100644 index 0000000000..efacb3376e --- /dev/null +++ b/backend/migrations/20260512200642_add_edited_at_to_variable.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE variable + DROP COLUMN IF EXISTS edited_by, + DROP COLUMN IF EXISTS edited_at; diff --git a/backend/migrations/20260512200642_add_edited_at_to_variable.up.sql b/backend/migrations/20260512200642_add_edited_at_to_variable.up.sql new file mode 100644 index 0000000000..f19d45de35 --- /dev/null +++ b/backend/migrations/20260512200642_add_edited_at_to_variable.up.sql @@ -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); diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index f20511ab14..7a6c2aaf1a 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -1480,6 +1480,11 @@ pub struct FlowWDraft { pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub draft: Option>>, + /// 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>, #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[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, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index de0f38cd34..100352adbd 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -93,6 +93,11 @@ pub struct ScriptWDraft { pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] pub draft: Option>>, + /// 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>, pub schema: Option, #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, @@ -170,6 +175,7 @@ impl ScriptWDraft { 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>( - &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>(&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>( - "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>( - &format!( - "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + sqlx::query_as::<_, ScriptWithStarred>(&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) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 258e268f88..945da4a614 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index a5e9146ddb..98b856fcf0 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -217,6 +217,11 @@ pub struct AppWithLastVersionAndDraft { pub draft: Option>>, #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, + /// 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>, } #[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 "#, ) diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 57e41bb487..c03fa4ef15 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -50,6 +50,10 @@ pub struct ListableVariable { pub labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ws_specific: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited_by: Option, } #[derive(Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index c50049fcd6..4c2a0ed670 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -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 = 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?;