refactor: remove draft sync layer and conflict modal

This commit is contained in:
Diego Imbert
2026-06-01 13:15:05 +02:00
parent 549c0926a1
commit bea1dfaaea
12 changed files with 23 additions and 999 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
-112
View File
@@ -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
+1 -216
View File
@@ -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).