mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
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:
+23
@@ -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"
|
||||
}
|
||||
+17
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user