mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
refactor: remove draft sync layer and conflict modal
This commit is contained in:
-49
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c"
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, now())\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING created_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310"
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4\n AND created_at > $5",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff"
|
||||
}
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path,\n typ as \"typ!: UserDraftItemKind\",\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "typ!: UserDraftItemKind",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"TextArray",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7f832370916794ab0e5645053688c24678f1519d49ee7263a86dba71d45b8e8c"
|
||||
}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path,\n typ as \"typ!: UserDraftItemKind\",\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND created_at > $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "typ!: UserDraftItemKind",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b"
|
||||
}
|
||||
@@ -7802,118 +7802,6 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/drafts/sync:
|
||||
post:
|
||||
summary: bi-directional user-draft sync
|
||||
operationId: syncDrafts
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
last_sync:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Server timestamp of the last successful sync. Omit on first sync.
|
||||
drafts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
typ:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
value:
|
||||
nullable: true
|
||||
description: Draft content. `null` (or omitted) means delete the draft at this path. Conflict semantics apply the same way to deletes.
|
||||
force:
|
||||
type: boolean
|
||||
description: Skip the conflict check for this entry and overwrite the server copy.
|
||||
required: [path, typ]
|
||||
required: [drafts]
|
||||
responses:
|
||||
"200":
|
||||
description: sync result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
missed_drafts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
typ:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
value: {}
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [path, typ, value, created_at]
|
||||
statuses:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [saved]
|
||||
path:
|
||||
type: string
|
||||
typ:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [status, path, typ, created_at]
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [deleted]
|
||||
path:
|
||||
type: string
|
||||
typ:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
required: [status, path, typ]
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [rejected]
|
||||
path:
|
||||
type: string
|
||||
typ:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
server_value: {}
|
||||
server_created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
incoming_value:
|
||||
nullable: true
|
||||
description: The value the client tried to push, or `null` if the client attempted a delete.
|
||||
required:
|
||||
- status
|
||||
- path
|
||||
- typ
|
||||
- server_value
|
||||
- server_created_at
|
||||
current_timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [missed_drafts, statuses, current_timestamp]
|
||||
|
||||
/w/{workspace}/drafts/users_with_draft/{kind}/{path}:
|
||||
get:
|
||||
summary: list users with a saved draft on a path
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Surfaces UserDraft sync conflicts one at a time. Mounted once near the
|
||||
* top of the tree (root layout) so any UserDraft.save call can enqueue
|
||||
* a conflict via `UserDraftConflictStore.enqueue` and have it shown to
|
||||
* the user without coordinating with a route component.
|
||||
*
|
||||
* Two conflict shapes:
|
||||
* • upsert rejected: `incoming_value` is non-null. The user tried to
|
||||
* save and lost the race. Offer "Overwrite server draft" / "Load
|
||||
* server draft".
|
||||
* • delete rejected: `incoming_value` is null. The user tried to
|
||||
* discard and lost the race. Offer "Delete anyway" / "Load server
|
||||
* draft".
|
||||
*/
|
||||
import { classNames } from '$lib/utils'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { AlertTriangle, CornerDownLeft } from 'lucide-svelte'
|
||||
import { UserDraftConflictStore } from '$lib/userDraftConflictStore.svelte'
|
||||
import { UserDraftDbSyncer, syncDrafts } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
const conflict = $derived(UserDraftConflictStore.current)
|
||||
const open = $derived(conflict !== undefined)
|
||||
const isDeleteAttempt = $derived(
|
||||
conflict !== undefined &&
|
||||
(conflict.rejected.incoming_value === null || conflict.rejected.incoming_value === undefined)
|
||||
)
|
||||
|
||||
const lastSyncDate = $derived(UserDraftDbSyncer.getLastSync())
|
||||
|
||||
let busy = $state(false)
|
||||
|
||||
async function forceLocal() {
|
||||
if (!conflict) return
|
||||
busy = true
|
||||
try {
|
||||
await syncDrafts({
|
||||
workspace: conflict.workspace,
|
||||
drafts: [
|
||||
{
|
||||
itemKind: conflict.itemKind,
|
||||
path: conflict.rejected.path,
|
||||
value: isDeleteAttempt ? null : conflict.rejected.incoming_value,
|
||||
force: true
|
||||
}
|
||||
]
|
||||
})
|
||||
UserDraftConflictStore.dismiss()
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not apply local change: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadServer() {
|
||||
if (!conflict) return
|
||||
UserDraft.save(conflict.itemKind, conflict.rejected.path, conflict.rejected.server_value, {
|
||||
workspace: conflict.workspace
|
||||
})
|
||||
UserDraftConflictStore.dismiss()
|
||||
}
|
||||
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 100 })
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open && conflict}
|
||||
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[9999]" role="dialog">
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
|
||||
'ease-out duration-300 opacity-100'
|
||||
)}
|
||||
></div>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class="relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 ease-out duration-300 opacity-100 translate-y-0 sm:scale-100"
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50"
|
||||
>
|
||||
<AlertTriangle class="text-amber-600 dark:text-amber-300" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-primary">
|
||||
{#if isDeleteAttempt}
|
||||
Couldn't discard your draft for <code>{conflict.rejected.path}</code>
|
||||
{:else}
|
||||
Your draft for <code>{conflict.rejected.path}</code> is out of date
|
||||
{/if}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-secondary">
|
||||
{#if isDeleteAttempt}
|
||||
Another session saved a newer draft for this {conflict.itemKind} since this tab last
|
||||
synced — discarding now would erase changes you haven't seen.
|
||||
{:else}
|
||||
Another session saved a newer draft for this {conflict.itemKind} since this tab last
|
||||
synced.
|
||||
{/if}
|
||||
</p>
|
||||
<dl
|
||||
class="mt-3 text-xs text-tertiary grid grid-cols-[max-content_1fr] gap-x-2 gap-y-1"
|
||||
>
|
||||
<dt>Last sync from this tab:</dt>
|
||||
<dd>
|
||||
{lastSyncDate ? new Date(lastSyncDate).toLocaleString() : 'never'}
|
||||
</dd>
|
||||
<dt>Server draft saved at:</dt>
|
||||
<dd>{new Date(conflict.rejected.server_created_at).toLocaleString()}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 mt-4 flex-wrap">
|
||||
<Button
|
||||
disabled={busy}
|
||||
on:click={loadServer}
|
||||
variant="default"
|
||||
size="sm"
|
||||
shortCut={{ key: 'Esc', withoutModifier: true }}
|
||||
>
|
||||
Load server draft
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
on:click={forceLocal}
|
||||
color="dark"
|
||||
size="sm"
|
||||
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
|
||||
variant="accent"
|
||||
>
|
||||
{isDeleteAttempt ? 'Discard anyway' : 'Overwrite server draft'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Module-level queue of UserDraft sync conflicts (drafts rejected by the
|
||||
* server because a newer version was saved since the client's last sync).
|
||||
*
|
||||
* Components anywhere in the app can call `enqueueConflicts()` to surface a
|
||||
* conflict; the single `UserDraftConflictModal` mounted in the root layout
|
||||
* consumes them one at a time so the user can decide per-conflict.
|
||||
*/
|
||||
import type { UserDraftItemKind } from './userDraft.svelte'
|
||||
import type { RejectedDraft } from './userDraftDbSyncer.svelte'
|
||||
|
||||
export type ConflictEntry = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
rejected: RejectedDraft
|
||||
}
|
||||
|
||||
let pending = $state<ConflictEntry[]>([])
|
||||
|
||||
export const UserDraftConflictStore = {
|
||||
get current(): ConflictEntry | undefined {
|
||||
return pending[0]
|
||||
},
|
||||
get hasAny(): boolean {
|
||||
return pending.length > 0
|
||||
},
|
||||
enqueue(entries: ConflictEntry[]): void {
|
||||
if (entries.length === 0) return
|
||||
pending.push(...entries)
|
||||
},
|
||||
dismiss(): void {
|
||||
pending.shift()
|
||||
},
|
||||
clear(): void {
|
||||
pending.length = 0
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Bi-directional sync layer between the local `UserDraft` autosave and the
|
||||
* server-side `draft` table. The transport is `DraftService.syncDrafts`,
|
||||
* which doubles as "what the server has for me that I haven't seen yet" and
|
||||
* "push these drafts up, rejecting any whose server copy moved forward
|
||||
* since my last sync".
|
||||
*/
|
||||
import type { UserDraftItemKind } from './userDraft.svelte'
|
||||
import { DraftService, type SyncDraftsResponse } from './gen'
|
||||
import { useLocalStorageValue } from './svelte5Utils.svelte'
|
||||
|
||||
const LAST_SYNC_KEY = 'userdraft/lastSync'
|
||||
|
||||
export type MissedDraft = SyncDraftsResponse['missed_drafts'][number]
|
||||
export type RejectedDraft = Extract<SyncDraftsResponse['statuses'][number], { status: 'rejected' }>
|
||||
|
||||
export type PendingDraft<V = unknown> = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
/**
|
||||
* Draft content. `null` (or omitted) signals a delete — the server
|
||||
* removes the row at this path, applying the same conflict semantics
|
||||
* as an upsert.
|
||||
*/
|
||||
value: V | null
|
||||
/**
|
||||
* Skip the conflict check for this single entry and overwrite the
|
||||
* server copy. Used by the conflict-resolution modal's "Overwrite
|
||||
* server draft" / "Delete anyway" actions; routine autosaves leave
|
||||
* this `false`.
|
||||
*/
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export type MissedDraftCallback = (drafts: MissedDraft[]) => void
|
||||
export type RejectedDraftsCallback = (rejected: RejectedDraft[]) => void
|
||||
|
||||
export type SyncOptions<V = unknown> = {
|
||||
workspace: string
|
||||
drafts: PendingDraft<V>[]
|
||||
onMissedDrafts?: MissedDraftCallback
|
||||
onDraftsRejected?: RejectedDraftsCallback
|
||||
}
|
||||
|
||||
// Setter-only callers can use `useLocalStorageValue` at module scope by
|
||||
// disabling the nested-mutation `$effect`. The lastSync slot is a flat
|
||||
// string updated exclusively via `cell.val = ...`, so the effect is
|
||||
// unnecessary.
|
||||
const lastSyncCell = useLocalStorageValue<string | undefined>(LAST_SYNC_KEY, undefined, 'string', {
|
||||
saveInitialValue: false
|
||||
})
|
||||
|
||||
function getLastSync(): string | undefined {
|
||||
return lastSyncCell.val
|
||||
}
|
||||
|
||||
function bumpLastSync(serverTimestamp: string): void {
|
||||
const previous = lastSyncCell.val
|
||||
if (!previous || new Date(serverTimestamp).getTime() > new Date(previous).getTime()) {
|
||||
lastSyncCell.val = serverTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediate sync. Caller is responsible for handling the missed/rejected
|
||||
* lists via the callbacks.
|
||||
*/
|
||||
export async function syncDrafts<V = unknown>(opts: SyncOptions<V>): Promise<void> {
|
||||
const lastSync = getLastSync()
|
||||
const payloadDrafts = opts.drafts.map((d) => ({
|
||||
path: d.path,
|
||||
typ: d.itemKind,
|
||||
value: d.value as any,
|
||||
force: d.force ?? false
|
||||
}))
|
||||
const result = await DraftService.syncDrafts({
|
||||
workspace: opts.workspace,
|
||||
requestBody: {
|
||||
last_sync: lastSync,
|
||||
drafts: payloadDrafts
|
||||
}
|
||||
})
|
||||
bumpLastSync(result.current_timestamp as unknown as string)
|
||||
|
||||
if (result.missed_drafts.length > 0 && opts.onMissedDrafts) {
|
||||
opts.onMissedDrafts(result.missed_drafts)
|
||||
}
|
||||
|
||||
const rejected = result.statuses.filter((s): s is RejectedDraft => s.status === 'rejected')
|
||||
if (rejected.length > 0 && opts.onDraftsRejected) {
|
||||
opts.onDraftsRejected(rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-workspace single-flight serializer. Pushes are merged into
|
||||
// `pendingPushReq` so concurrent calls coalesce instead of fan-out; the
|
||||
// leader (the call that found no flush in progress) drains the queue.
|
||||
type WorkspaceState = {
|
||||
isFlushing: boolean
|
||||
pendingPushReq: SyncOptions | undefined
|
||||
}
|
||||
|
||||
const workspaceStates = new Map<string, WorkspaceState>()
|
||||
|
||||
/**
|
||||
* Merge two `SyncOptions` into one. Drafts are keyed by `(itemKind, path)`
|
||||
* with later wins, so a sequence like push([X₁]), push([X₂, Y₂]),
|
||||
* push([Y₃]) ends up syncing [X₂, Y₃] — keys only in `prev` survive even
|
||||
* when `next` doesn't repeat them. Callbacks fall back to `prev` when
|
||||
* `next` doesn't provide one, so a caller that doesn't pass callbacks
|
||||
* never silently disarms an earlier caller that did.
|
||||
*/
|
||||
function mergeSyncOptions(prev: SyncOptions, next: SyncOptions): SyncOptions {
|
||||
const merged = new Map<string, PendingDraft>()
|
||||
for (const d of prev.drafts) merged.set(`${d.itemKind}|${d.path}`, d)
|
||||
for (const d of next.drafts) merged.set(`${d.itemKind}|${d.path}`, d)
|
||||
return {
|
||||
workspace: next.workspace,
|
||||
drafts: [...merged.values()],
|
||||
onMissedDrafts: next.onMissedDrafts ?? prev.onMissedDrafts,
|
||||
onDraftsRejected: next.onDraftsRejected ?? prev.onDraftsRejected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a push. At most one `syncDrafts` is in flight per workspace; any
|
||||
* pushes that arrive during a flight are merged via `mergeSyncOptions` and
|
||||
* sent as a single follow-up request when the in-flight call resolves.
|
||||
*
|
||||
* If `syncDrafts` throws while newer work is already queued, the error is
|
||||
* dropped — the next request supersedes it. Otherwise the error propagates
|
||||
* out of the leader's `pushDrafts` call.
|
||||
*/
|
||||
async function pushDrafts<V = unknown>(opts: SyncOptions<V>): Promise<void> {
|
||||
let state = workspaceStates.get(opts.workspace)
|
||||
if (!state) {
|
||||
state = { isFlushing: false, pendingPushReq: undefined }
|
||||
workspaceStates.set(opts.workspace, state)
|
||||
}
|
||||
state.pendingPushReq =
|
||||
state.pendingPushReq === undefined
|
||||
? (opts as SyncOptions)
|
||||
: mergeSyncOptions(state.pendingPushReq, opts as SyncOptions)
|
||||
if (state.isFlushing) return
|
||||
state.isFlushing = true
|
||||
try {
|
||||
while (state.pendingPushReq !== undefined) {
|
||||
const next = state.pendingPushReq
|
||||
state.pendingPushReq = undefined
|
||||
try {
|
||||
await syncDrafts(next)
|
||||
} catch (e) {
|
||||
if (state.pendingPushReq === undefined) throw e
|
||||
// Else: newer pushes arrived during the failed sync — drop
|
||||
// the error and let the loop send the merged follow-up.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.isFlushing = false
|
||||
}
|
||||
}
|
||||
|
||||
export const UserDraftDbSyncer = {
|
||||
getLastSync,
|
||||
pushDrafts
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
|
||||
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
|
||||
import ForkConflictModal from '$lib/components/ForkConflictModal.svelte'
|
||||
import UserDraftConflictModal from '$lib/components/common/confirmationModal/UserDraftConflictModal.svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
isPremiumStore,
|
||||
@@ -857,8 +856,6 @@
|
||||
|
||||
<ForkConflictModal />
|
||||
|
||||
<UserDraftConflictModal />
|
||||
|
||||
<Modal2
|
||||
title="Forking {$workspaceStore}"
|
||||
target="#content"
|
||||
|
||||
Reference in New Issue
Block a user