mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 08:00:59 +00:00
refactor: remove draft sync layer and conflict modal
This commit is contained in:
@@ -10,7 +10,7 @@ use crate::db::{ApiAuthed, DB};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::{get, post},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -59,7 +59,6 @@ pub enum UserDraftItemKind {
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/sync", post(sync_drafts))
|
||||
.route(
|
||||
"/users_with_draft/{kind}/{*path}",
|
||||
get(list_users_with_draft_on_path),
|
||||
@@ -67,220 +66,6 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get/{kind}/{*path}", get(get_draft_for_user))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct IncomingDraft {
|
||||
pub path: String,
|
||||
pub typ: UserDraftItemKind,
|
||||
/// `null` (or omitted) means delete the draft at this path. Conflict
|
||||
/// semantics apply the same way to deletions as to upserts.
|
||||
#[serde(default)]
|
||||
pub value: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
|
||||
/// When true, skip the conflict check for this entry and overwrite the
|
||||
/// server copy. Only the matching entry is forced — other entries in
|
||||
/// the same batch still run through the normal conflict check.
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SyncDraftsRequest {
|
||||
/// Server timestamp of the client's last successful sync. Used both to
|
||||
/// stream back drafts written by other sessions since then
|
||||
/// (`missed_drafts`) and to detect conflicts when the client tries to
|
||||
/// push a draft whose server copy moved forward (`status: rejected`).
|
||||
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub drafts: Vec<IncomingDraft>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct MissedDraft {
|
||||
pub path: String,
|
||||
pub typ: UserDraftItemKind,
|
||||
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "status", rename_all = "lowercase")]
|
||||
pub enum DraftSyncStatus {
|
||||
Saved {
|
||||
path: String,
|
||||
typ: UserDraftItemKind,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
Deleted {
|
||||
path: String,
|
||||
typ: UserDraftItemKind,
|
||||
},
|
||||
Rejected {
|
||||
path: String,
|
||||
typ: UserDraftItemKind,
|
||||
/// Current server copy at conflict-detection time.
|
||||
server_value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
|
||||
server_created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// The value the client tried to push. `None` when the client
|
||||
/// attempted a delete; the modal interprets this as "you tried to
|
||||
/// delete, but the server has a newer version".
|
||||
incoming_value: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct SyncDraftsResponse {
|
||||
pub missed_drafts: Vec<MissedDraft>,
|
||||
pub statuses: Vec<DraftSyncStatus>,
|
||||
pub current_timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
async fn sync_drafts(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<SyncDraftsRequest>,
|
||||
) -> Result<Json<SyncDraftsResponse>> {
|
||||
let email = &authed.email;
|
||||
|
||||
let missed_drafts = if let Some(last_sync) = req.last_sync {
|
||||
sqlx::query_as!(
|
||||
MissedDraft,
|
||||
r#"SELECT path,
|
||||
typ as "typ!: UserDraftItemKind",
|
||||
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email = $2
|
||||
AND created_at > $3"#,
|
||||
&w_id,
|
||||
email,
|
||||
last_sync,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
} else {
|
||||
// Initial sync — return everything the user has on the server.
|
||||
sqlx::query_as!(
|
||||
MissedDraft,
|
||||
r#"SELECT path,
|
||||
typ as "typ!: UserDraftItemKind",
|
||||
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email = $2"#,
|
||||
&w_id,
|
||||
email,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut statuses = Vec::with_capacity(req.drafts.len());
|
||||
|
||||
for incoming in &req.drafts {
|
||||
if !incoming.force {
|
||||
if let Some(last_sync) = req.last_sync {
|
||||
let conflict = sqlx::query!(
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>", created_at
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email = $2
|
||||
AND path = $3
|
||||
AND typ = $4
|
||||
AND created_at > $5"#,
|
||||
&w_id,
|
||||
email,
|
||||
incoming.path,
|
||||
incoming.typ as UserDraftItemKind,
|
||||
last_sync,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = conflict {
|
||||
statuses.push(DraftSyncStatus::Rejected {
|
||||
path: incoming.path.clone(),
|
||||
typ: incoming.typ,
|
||||
server_value: row.value,
|
||||
server_created_at: row.created_at,
|
||||
incoming_value: incoming.value.as_ref().map(|v| {
|
||||
sqlx::types::Json(
|
||||
serde_json::value::RawValue::from_string(v.0.get().to_string())
|
||||
.expect("RawValue round-trip"),
|
||||
)
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match &incoming.value {
|
||||
Some(value) => {
|
||||
let row = sqlx::query!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::text::json, now())
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()
|
||||
RETURNING created_at"#,
|
||||
&w_id,
|
||||
email,
|
||||
incoming.path,
|
||||
incoming.typ as UserDraftItemKind,
|
||||
serde_json::to_string(value).unwrap(),
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
statuses.push(DraftSyncStatus::Saved {
|
||||
path: incoming.path.clone(),
|
||||
typ: incoming.typ,
|
||||
created_at: row.created_at,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
// Delete-only path. Idempotent: the DELETE is a no-op if
|
||||
// the row was already gone (concurrent delete from another
|
||||
// tab) — we still report `Deleted` so the client clears
|
||||
// its pending state.
|
||||
sqlx::query!(
|
||||
r#"DELETE FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email = $2
|
||||
AND path = $3
|
||||
AND typ = $4"#,
|
||||
&w_id,
|
||||
email,
|
||||
incoming.path,
|
||||
incoming.typ as UserDraftItemKind,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
statuses.push(DraftSyncStatus::Deleted {
|
||||
path: incoming.path.clone(),
|
||||
typ: incoming.typ,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute after the inserts so the response's `current_timestamp` is
|
||||
// >= every just-saved row's `created_at`. Otherwise a client that
|
||||
// re-syncs immediately would see its own writes as newer than its
|
||||
// `last_sync` and get rejected on the next push.
|
||||
let current_timestamp = sqlx::query_scalar!("SELECT now()")
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.expect("now() is never null");
|
||||
|
||||
Ok(Json(SyncDraftsResponse {
|
||||
missed_drafts,
|
||||
statuses,
|
||||
current_timestamp,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct UserWithDraft {
|
||||
/// `None` represents a legacy workspace-level draft (no owner).
|
||||
|
||||
Reference in New Issue
Block a user