From bea1dfaaea842f49d747dc4bfcdc19f60c20bd89 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 1 Jun 2026 13:15:05 +0200 Subject: [PATCH] refactor: remove draft sync layer and conflict modal --- ...420468770630c1805058edd14679062162b8c.json | 49 ---- ...bcd6885cc499823ee1d52bf2631dd3413f310.json | 58 ----- ...9549970aa645f9e23c2525311fc1161fd21ff.json | 64 ------ ...279cd7ada3a44a4157fbad52890ce5896174d.json | 73 ------ ...8c24678f1519d49ee7263a86dba71d45b8e8c.json | 22 ++ ...34ab160ea1340aa65c4d1d3963ba384b8d39b.json | 74 ------ backend/windmill-api/openapi.yaml | 112 --------- backend/windmill-api/src/drafts.rs | 217 +----------------- .../UserDraftConflictModal.svelte | 147 ------------ .../src/lib/userDraftConflictStore.svelte.ts | 37 --- frontend/src/lib/userDraftDbSyncer.svelte.ts | 166 -------------- .../src/routes/(root)/(logged)/+layout.svelte | 3 - 12 files changed, 23 insertions(+), 999 deletions(-) delete mode 100644 backend/.sqlx/query-0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c.json delete mode 100644 backend/.sqlx/query-29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310.json delete mode 100644 backend/.sqlx/query-2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff.json delete mode 100644 backend/.sqlx/query-6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d.json create mode 100644 backend/.sqlx/query-7f832370916794ab0e5645053688c24678f1519d49ee7263a86dba71d45b8e8c.json delete mode 100644 backend/.sqlx/query-c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b.json delete mode 100644 frontend/src/lib/components/common/confirmationModal/UserDraftConflictModal.svelte delete mode 100644 frontend/src/lib/userDraftConflictStore.svelte.ts delete mode 100644 frontend/src/lib/userDraftDbSyncer.svelte.ts diff --git a/backend/.sqlx/query-0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c.json b/backend/.sqlx/query-0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c.json deleted file mode 100644 index 28bb8fd4de..0000000000 --- a/backend/.sqlx/query-0f7ad07f2bef7e55d6fc15db11d420468770630c1805058edd14679062162b8c.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310.json b/backend/.sqlx/query-29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310.json deleted file mode 100644 index faddf0a546..0000000000 --- a/backend/.sqlx/query-29190ba48f4986eaf35247e9754bcd6885cc499823ee1d52bf2631dd3413f310.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff.json b/backend/.sqlx/query-2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff.json deleted file mode 100644 index 12393c6ebb..0000000000 --- a/backend/.sqlx/query-2d8657ce809262cc261a381bcb19549970aa645f9e23c2525311fc1161fd21ff.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT value as \"value!: sqlx::types::Json>\", 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>", - "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" -} diff --git a/backend/.sqlx/query-6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d.json b/backend/.sqlx/query-6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d.json deleted file mode 100644 index 61bbf73af8..0000000000 --- a/backend/.sqlx/query-6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path,\n typ as \"typ!: UserDraftItemKind\",\n value as \"value!: sqlx::types::Json>\",\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>", - "type_info": "Json" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "6aa60770e0ca16527354aae1593279cd7ada3a44a4157fbad52890ce5896174d" -} diff --git a/backend/.sqlx/query-7f832370916794ab0e5645053688c24678f1519d49ee7263a86dba71d45b8e8c.json b/backend/.sqlx/query-7f832370916794ab0e5645053688c24678f1519d49ee7263a86dba71d45b8e8c.json new file mode 100644 index 0000000000..173d8f2281 --- /dev/null +++ b/backend/.sqlx/query-7f832370916794ab0e5645053688c24678f1519d49ee7263a86dba71d45b8e8c.json @@ -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" +} diff --git a/backend/.sqlx/query-c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b.json b/backend/.sqlx/query-c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b.json deleted file mode 100644 index 3e9c78ccbb..0000000000 --- a/backend/.sqlx/query-c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path,\n typ as \"typ!: UserDraftItemKind\",\n value as \"value!: sqlx::types::Json>\",\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>", - "type_info": "Json" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Timestamptz" - ] - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "c5c5ee9abb457ffdf34d097738134ab160ea1340aa65c4d1d3963ba384b8d39b" -} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 92e3e98d81..e83c1d5687 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 6e3532da37..4de7aa90be 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -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>>, - /// 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>, - pub drafts: Vec, -} - -#[derive(Serialize, Debug)] -pub struct MissedDraft { - pub path: String, - pub typ: UserDraftItemKind, - pub value: sqlx::types::Json>, - pub created_at: chrono::DateTime, -} - -#[derive(Serialize, Debug)] -#[serde(tag = "status", rename_all = "lowercase")] -pub enum DraftSyncStatus { - Saved { - path: String, - typ: UserDraftItemKind, - created_at: chrono::DateTime, - }, - Deleted { - path: String, - typ: UserDraftItemKind, - }, - Rejected { - path: String, - typ: UserDraftItemKind, - /// Current server copy at conflict-detection time. - server_value: sqlx::types::Json>, - server_created_at: chrono::DateTime, - /// 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>>, - }, -} - -#[derive(Serialize, Debug)] -pub struct SyncDraftsResponse { - pub missed_drafts: Vec, - pub statuses: Vec, - pub current_timestamp: chrono::DateTime, -} - -async fn sync_drafts( - authed: ApiAuthed, - Extension(db): Extension, - Path(w_id): Path, - Json(req): Json, -) -> Result> { - 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>", - 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>", - 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>", 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). diff --git a/frontend/src/lib/components/common/confirmationModal/UserDraftConflictModal.svelte b/frontend/src/lib/components/common/confirmationModal/UserDraftConflictModal.svelte deleted file mode 100644 index 224f6720e1..0000000000 --- a/frontend/src/lib/components/common/confirmationModal/UserDraftConflictModal.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - -{#if open && conflict} - -{/if} diff --git a/frontend/src/lib/userDraftConflictStore.svelte.ts b/frontend/src/lib/userDraftConflictStore.svelte.ts deleted file mode 100644 index 1c058da009..0000000000 --- a/frontend/src/lib/userDraftConflictStore.svelte.ts +++ /dev/null @@ -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([]) - -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 - } -} diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts deleted file mode 100644 index 549305b369..0000000000 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ /dev/null @@ -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 - -export type PendingDraft = { - 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 = { - workspace: string - drafts: PendingDraft[] - 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(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(opts: SyncOptions): Promise { - 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() - -/** - * 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() - 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(opts: SyncOptions): Promise { - 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 -} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 4d6f6e77d5..c6fca60e37 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -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 @@ - -