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,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"