feat: wire UserDraft.save through DbSyncer + conflict modal

This commit is contained in:
Diego Imbert
2026-05-28 00:35:26 +02:00
parent 1f40a8b787
commit 5bb0837baf
4 changed files with 251 additions and 13 deletions
@@ -0,0 +1,130 @@
<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.
*/
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 lastSyncDate = $derived(UserDraftDbSyncer.getLastSync())
let busy = $state(false)
async function overwriteServer() {
if (!conflict) return
busy = true
try {
await syncDrafts({
workspace: conflict.workspace,
user: conflict.user,
drafts: [
{
itemKind: conflict.itemKind,
path: conflict.rejected.path,
value: conflict.rejected.incoming_value
}
],
force: true
})
UserDraftConflictStore.dismiss()
} catch (e) {
sendUserToast(`Could not overwrite server draft: ${e.body ?? e.message}`, true)
} finally {
busy = false
}
}
function loadServer() {
if (!conflict) return
// `_skipSync` so the write doesn't immediately bounce back to the
// server (which already has this value).
UserDraft.save(conflict.itemKind, conflict.rejected.path, conflict.rejected.server_value, {
workspace: conflict.workspace,
_skipSync: true
})
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">
Your draft for <code>{conflict.rejected.path}</code> is out of date
</h3>
<p class="mt-2 text-sm text-secondary">
Another session saved a newer draft for this {conflict.itemKind} since this tab last
synced.
</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={overwriteServer}
color="dark"
size="sm"
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
variant="accent"
>
Overwrite server draft
</Button>
</div>
</div>
</div>
</div>
</div>
{/if}
+75 -13
View File
@@ -1,8 +1,10 @@
import { get } from 'svelte/store'
import { onDestroy, untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import { workspaceStore } from './stores'
import { userStore, workspaceStore } from './stores'
import { useLocalStorageValue } from './svelte5Utils.svelte'
import { UserDraftDbSyncer, isSyncableKind } from './userDraftDbSyncer.svelte'
import { UserDraftConflictStore } from './userDraftConflictStore.svelte'
export const USER_DRAFT_ITEM_KINDS = [
'script',
@@ -348,8 +350,49 @@ export function localDraftDiffers<V>(
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
}
/**
* Persist a draft to localStorage and (when the kind has a backend `draft`
* row) push it through the DbSyncer. Internal `_skipSync` opts pass-through
* lets the syncer write missed drafts back to LS without re-syncing them.
*/
function pushToSyncer(
itemKind: UserDraftItemKind,
path: string,
value: unknown,
workspace: string
): void {
if (!isSyncableKind(itemKind)) return
const user = get(userStore)?.username
if (!user) return
UserDraftDbSyncer.pushDrafts({
workspace,
user,
drafts: [{ itemKind, path, value }],
onMissedDrafts: (drafts) => {
for (const d of drafts) {
UserDraft.save(d.typ as UserDraftItemKind, d.path, d.value, {
workspace,
_skipSync: true
})
}
},
onDraftsRejected: (rejected) => {
UserDraftConflictStore.enqueue(
rejected.map((r) => ({
workspace,
user,
itemKind: r.typ,
rejected: r
}))
)
}
})
}
type SaveOptions = UserDraftOptions & { _skipSync?: boolean }
export const UserDraft = {
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: SaveOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
@@ -361,18 +404,21 @@ export const UserDraft = {
const meta = extractMeta(current)
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
} else {
// No live handle: preserve any persisted meta so the staleness
// signal survives a write while the editor is closed.
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
try {
localStorage.setItem(
localStorageKey(ws, itemKind, path),
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
)
} catch (e) {
console.error('UserDraft.save: localStorage write failed', e)
}
}
// No live handle: preserve any persisted meta so the staleness
// signal survives a write while the editor is closed.
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
try {
localStorage.setItem(
localStorageKey(ws, itemKind, path),
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
)
} catch (e) {
console.error('UserDraft.save: localStorage write failed', e)
if (!opts?._skipSync) {
pushToSyncer(itemKind, path, value, ws)
}
},
@@ -729,6 +775,22 @@ function acquireEntry(
undefined,
useLocalStorageOptions
)
// Mirror live-handle writes (e.g. `handle.draft = X` from an editor's
// $effect) to the DbSyncer. Skip the first run — the initial value
// came from localStorage or the default, not from a user mutation, so
// echoing it back would create a redundant request on every editor
// mount.
let firstRun = true
$effect(() => {
const stored = stateRef!.val
if (firstRun) {
firstRun = false
return
}
const value = stored === undefined ? undefined : (stored.value as unknown)
if (value === undefined) return
pushToSyncer(itemKind, path, value, workspace)
})
})
if (stateRef) {
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
@@ -0,0 +1,37 @@
/**
* 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 { RejectedDraft, SyncableItemKind } from './userDraftDbSyncer.svelte'
export type ConflictEntry = {
workspace: string
user: string
itemKind: SyncableItemKind
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
}
}
@@ -16,6 +16,8 @@
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 { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import {
enterpriseLicense,
isPremiumStore,
@@ -99,6 +101,11 @@
goto('/user/login')
}
// Bind UserDraftDbSyncer's lastSync cell to this layout's Svelte scope.
// Without this it still works via raw localStorage reads, but the value
// isn't reactive across tabs.
UserDraftDbSyncer.mount()
function onQueryChangeUserSettings() {
if (userSettings && page.url.hash.startsWith(USER_SETTINGS_HASH)) {
const mcpMode = page.url.hash.includes('-mcp')
@@ -856,6 +863,8 @@
<ForkConflictModal />
<UserDraftConflictModal />
<Modal2
title="Forking {$workspaceStore}"
target="#content"