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).
@@ -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"