mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
refactor(drafts): drop LS-era pipeline; backend is canonical on load
The PR's iteration left behind a meta/staleness pipeline carried over
from the localStorage era — per-rev tracking, a LocalDraftStaleModal, a
'Restored from local storage' toast, and a localDraft-vs-backend
comparison branch in every editor loader. With drafts now living in
the DB and the optimistic-concurrency lastSync check handling
divergence, that whole stack is dead weight.
Worse, the comparison branch caused 'Load from server' in the conflict
modal to do nothing: the loader preferred the in-memory cell over the
backend, so the user-clicked 'load from server' just re-displayed the
local edits AND fired two confusing toasts (Restored from local
storage + Loaded your saved draft).
The rip:
* userDraft.svelte.ts: drop UserDraftMeta, StoredDraft.meta,
checkStaleness, UserDraftStalenessCause, normalizeForCompare,
localDraftDiffers, saveMeta, getMeta, setDraftAndMeta, setMeta,
handle.meta/setDraftAndMeta/setMeta, force option. Handle is now
just { draft }.
* userDraftToast.ts: drop notifyRestoredFromLocal +
RestoreFromLocalActions. Update copy.
* LocalDraftStaleModal.svelte: deleted.
* AppEditor.svelte: drop initialRevs prop and the firstMirror
wipe-then-restore dance (it existed only to consume the meta-mismatch
skip slot).
* All 4 editor routes: backend is canonical on load — the in-memory
cell is overwritten with the deployed+draft overlay, the syncer's
seed guard swallows the first write so we don't POST it back.
* VariableEditor / ResourceEditor: drop the staleness pipeline + rev
bookkeeping; backend wins on open.
* useTriggerDraftSync.svelte.ts: inline the JSON-normalize + deepEqual
utility as a private cfgDiffers helper (kept for the form-vs-deployed
dirty check, which is a genuine semantic compare, not LS legacy).
* copilot core.ts / userDraftAdapter.ts: drop meta argument from
saveAppDraft, loadAppDraftValue, write*Draft. Test assertions on
getMeta dropped.
Net: -22 typecheck errors, fewer moving parts, conflict modal works.
EOF
)
This commit is contained in:
@@ -13,8 +13,7 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
|
||||
interface Props {
|
||||
canSave?: boolean
|
||||
@@ -75,46 +74,6 @@
|
||||
let existedInitially: Record<string, boolean> = $state({})
|
||||
let fetchedResources: Record<string, Resource> = $state({})
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
// Backend `edited_at` per workspace — the rev the staleness check
|
||||
// compares the local autosave's recorded rev against. Resources have
|
||||
// no DB-draft concept, so only `remoteRev` is ever populated.
|
||||
let fetchedRev: Record<string, string | undefined> = $state({})
|
||||
|
||||
// Local-draft staleness modal: opened when the backend resource moved
|
||||
// on (someone else edited it) since the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let pendingStale: { ws: string; backend: ResourceState } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingStale) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { ws, backend } = pendingStale
|
||||
// Drop the divergent autosave and reset the handle to the freshly
|
||||
// fetched backend state. A later edit re-creates the autosave and
|
||||
// the seeding effect records the new rev.
|
||||
UserDraft.discard('resource', initialPath ?? '', backend, { workspace: ws })
|
||||
initialStates[ws] = $state.snapshot(backend) as ResourceState
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingStale) {
|
||||
const { ws } = pendingStale
|
||||
// Ack the new backend rev so the modal doesn't fire again until
|
||||
// the backend moves once more. Keeps the local autosave intact.
|
||||
UserDraft.saveMeta(
|
||||
'resource',
|
||||
initialPath ?? '',
|
||||
{ remoteRev: fetchedRev[ws] },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<ResourceState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
@@ -259,7 +218,6 @@
|
||||
// `ResourceState` shape — the editor reads it directly.
|
||||
const savedDraftState = (r as any).draft as ResourceState | undefined
|
||||
fetchedResources[ws] = r
|
||||
fetchedRev[ws] = r.edited_at
|
||||
// The deployed baseline, translated into the editor's
|
||||
// `ResourceState` shape. Kept as the dirty-check reference
|
||||
// so the "unsaved changes" banner compares draft-vs-deployed
|
||||
@@ -276,34 +234,6 @@
|
||||
// What the editor opens with: the saved draft if present,
|
||||
// otherwise the deployed.
|
||||
const s: ResourceState = savedDraftState ?? deployedState
|
||||
// Reconcile the local autosave with the backend before the
|
||||
// handle is registered. If the backend moved on since the
|
||||
// autosave was written (recorded rev != current rev) surface
|
||||
// the staleness modal; otherwise the form is just showing the
|
||||
// user's unsaved work — a toast with a "Reset to deployed"
|
||||
// escape is enough.
|
||||
const persisted = UserDraft.get<ResourceState>('resource', initialPath ?? '', {
|
||||
workspace: ws
|
||||
})
|
||||
const previousMeta = UserDraft.getMeta('resource', initialPath ?? '', { workspace: ws })
|
||||
if (persisted !== undefined && !deepEqual(persisted, s)) {
|
||||
const cause = checkStaleness(previousMeta, r.edited_at)
|
||||
if (cause) {
|
||||
pendingStale = { ws, backend: s }
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy autosave (no rev recorded) — backfill so the
|
||||
// next backend change is detectable as drift.
|
||||
UserDraft.saveMeta(
|
||||
'resource',
|
||||
initialPath ?? '',
|
||||
{ remoteRev: r.edited_at },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
existedInitially[ws] = true
|
||||
@@ -316,25 +246,6 @@
|
||||
})
|
||||
})
|
||||
|
||||
// Seed the staleness rev the moment a real autosave appears. Until the
|
||||
// user's first edit diverges the handle's draft from the backend
|
||||
// baseline there's no autosave to attach a rev to; once it does, record
|
||||
// the backend rev captured at fetch time so a later external edit is
|
||||
// detectable as drift on the next open. Self-limiting: after the write
|
||||
// `meta.remoteRev` is set so the guard fails on the re-run.
|
||||
$effect(() => {
|
||||
for (const ws of Object.keys(states)) {
|
||||
const h = states[ws]
|
||||
const rev = fetchedRev[ws]
|
||||
const baseline = initialStates[ws]
|
||||
if (!h || rev === undefined || baseline === undefined) continue
|
||||
const draft = h.draft
|
||||
if (draft === undefined || deepEqual(draft, baseline)) continue
|
||||
if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue
|
||||
untrack(() => h.setMeta({ remoteRev: rev }))
|
||||
}
|
||||
})
|
||||
|
||||
// Keep current.path bound to the outer `path` prop for consumers
|
||||
$effect(() => {
|
||||
if (current) path = current.path
|
||||
@@ -452,13 +363,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause="version"
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-6 py-2">
|
||||
{#if otherDirty.length > 0}
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import type { UserExt } from '$lib/stores'
|
||||
import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -43,41 +42,6 @@
|
||||
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
||||
let selected: string | undefined = $state(undefined)
|
||||
let pathError = $state('')
|
||||
// Backend `edited_at` per workspace — the rev the staleness check
|
||||
// compares the local autosave's recorded rev against. Variables have
|
||||
// no DB-draft concept, so only `remoteRev` is ever populated.
|
||||
let fetchedRev: Record<string, string | undefined> = $state({})
|
||||
|
||||
// Local-draft staleness modal: opened when the backend variable moved
|
||||
// on (someone else edited it) since the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let pendingStale: { ws: string; backend: VariableState } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingStale) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { ws, backend } = pendingStale
|
||||
UserDraft.discard('variable', editPath ?? '', backend, { workspace: ws })
|
||||
initialStates[ws] = $state.snapshot(backend) as VariableState
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingStale) {
|
||||
const { ws } = pendingStale
|
||||
UserDraft.saveMeta(
|
||||
'variable',
|
||||
editPath ?? '',
|
||||
{ remoteRev: fetchedRev[ws] },
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
pendingStale = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
const handlesArray = UserDraft.useMany<VariableState>(() =>
|
||||
workspaceSpecs.map((s) => ({
|
||||
@@ -171,7 +135,6 @@
|
||||
// draft (if any) sits in `.draft` as the editor's internal
|
||||
// `VariableState` shape — the editor reads it directly.
|
||||
const savedDraftState = (v as any).draft as VariableState | undefined
|
||||
fetchedRev[ws] = v.edited_at
|
||||
// The deployed baseline, translated into the editor's
|
||||
// `VariableState` shape. Kept as the dirty-check reference
|
||||
// so the "unsaved changes" banner compares draft-vs-deployed
|
||||
@@ -191,23 +154,6 @@
|
||||
// What the editor opens with: the saved draft if present,
|
||||
// otherwise the deployed.
|
||||
const s: VariableState = savedDraftState ?? deployedState
|
||||
// See ResourceEditor for the same pattern: a backend that
|
||||
// moved on since the autosave was written → staleness modal;
|
||||
// otherwise just a "showing your local autosave" toast with
|
||||
// a "Reset to deployed" escape.
|
||||
const persisted = UserDraft.get<VariableState>('variable', p, { workspace: ws })
|
||||
const previousMeta = UserDraft.getMeta('variable', p, { workspace: ws })
|
||||
if (persisted !== undefined && !deepEqual(persisted, s)) {
|
||||
const cause = checkStaleness(previousMeta, v.edited_at)
|
||||
if (cause) {
|
||||
pendingStale = { ws, backend: s }
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws })
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureHandle(ws, s)
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
existedInitially[ws] = true
|
||||
@@ -217,22 +163,6 @@
|
||||
})
|
||||
})
|
||||
|
||||
// Seed the staleness rev once a real autosave appears (see
|
||||
// ResourceEditor for the rationale). Self-limiting via the
|
||||
// meta-already-set guard.
|
||||
$effect(() => {
|
||||
for (const ws of Object.keys(states)) {
|
||||
const h = states[ws]
|
||||
const rev = fetchedRev[ws]
|
||||
const baseline = initialStates[ws]
|
||||
if (!h || rev === undefined || baseline === undefined) continue
|
||||
const draft = h.draft
|
||||
if (draft === undefined || deepEqual(draft, baseline)) continue
|
||||
if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue
|
||||
untrack(() => h.setMeta({ remoteRev: rev }))
|
||||
}
|
||||
})
|
||||
|
||||
function reset() {
|
||||
// Clearing workspaceSpecs triggers useMany's reconcile to release
|
||||
// every acquired entry. The $derived `states` then collapses to {}.
|
||||
@@ -333,13 +263,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause="version"
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem">
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
sendUserToast,
|
||||
urlParamsToObject
|
||||
} from '$lib/utils'
|
||||
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import AppPreview from './AppPreview.svelte'
|
||||
import ComponentList from './componentsPanel/ComponentList.svelte'
|
||||
import ContextPanel from './contextPanel/ContextPanel.svelte'
|
||||
@@ -79,7 +79,6 @@
|
||||
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
|
||||
onSavedNewAppPath,
|
||||
onNavigate,
|
||||
initialRevs,
|
||||
onResetToDeployed
|
||||
}: AppEditorProps = $props()
|
||||
|
||||
@@ -118,38 +117,16 @@
|
||||
// (template/hub loads, etc.).
|
||||
const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app))
|
||||
const appStore = writable<App>(stateApp)
|
||||
// Captured once on mount: the load-time revs are only used as the
|
||||
// seed meta on the very first persist of this entry. After that the
|
||||
// handle's own meta wins.
|
||||
const capturedInitialRevs = untrack(() => initialRevs)
|
||||
// `useLocalStorageValue`'s `saveInitialValue: false` skips the first
|
||||
// `set val` that DIFFERS from the loaded LS state — meant to absorb a
|
||||
// route's "load baseline" write. In AppEditor's $effect-mirror pattern
|
||||
// the loaded baseline always matches LS (stateApp is initialised from
|
||||
// the handle's draft), so the skip slot survives until the user's
|
||||
// FIRST edit and silently swallows it. Consume the slot up-front with
|
||||
// a wipe-then-restore pair: the wipe sets state.val = undefined
|
||||
// in-memory (the consumption side-effect of skipNextWrite, which
|
||||
// suppresses the localStorage delete the wipe would otherwise schedule),
|
||||
// and the restore immediately puts the value+meta back. Net effect: LS
|
||||
// gets re-written once on mount and user edits persist normally.
|
||||
let firstMirror = true
|
||||
// Mirror local `stateApp` mutations (drag/drop, settings edits, etc.)
|
||||
// back into the autosave cell. The first mirror writes the baseline
|
||||
// back through the handle — `acquireEntry`'s `skipNextWrite` swallows
|
||||
// that as the seed so it doesn't POST. Subsequent writes (user edits)
|
||||
// fire the syncer normally.
|
||||
$effect(() => {
|
||||
readFieldsRecursively(stateApp)
|
||||
if (!appDraftHandle) return
|
||||
untrack(() => {
|
||||
// Resolve the meta to attach BEFORE the wipe — the wipe clears
|
||||
// in-memory meta and would otherwise force-seed `initialRevs`
|
||||
// even when the handle had real meta.
|
||||
const currentMeta = appDraftHandle.meta
|
||||
const hasMeta =
|
||||
currentMeta.remoteRev !== undefined || currentMeta.remoteDraftRev !== undefined
|
||||
const meta: UserDraftMeta = hasMeta ? currentMeta : (capturedInitialRevs ?? {})
|
||||
if (firstMirror) {
|
||||
firstMirror = false
|
||||
appDraftHandle.setDraftAndMeta(undefined, {})
|
||||
}
|
||||
appDraftHandle.setDraftAndMeta(stateApp, meta)
|
||||
appDraftHandle.draft = stateApp
|
||||
})
|
||||
})
|
||||
const selectedComponent = writable<string[] | undefined>(undefined)
|
||||
|
||||
@@ -163,16 +163,6 @@ export interface AppEditorProps {
|
||||
onSavedNewAppPath?: (path: string) => void
|
||||
/** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */
|
||||
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
||||
/**
|
||||
* Backend revs at the load that produced `app`. Used as the seed
|
||||
* `UserDraft` meta on the first local autosave: until the handle has
|
||||
* its own meta (set on a previous reload, or by route backfill), the
|
||||
* mirror `$effect` injects these revs so the next reload's staleness
|
||||
* check has something to compare the current backend rev against.
|
||||
* Without this, the first deploy-after-edit can't be detected as
|
||||
* drift — `previousMeta` would be empty and the modal wouldn't fire.
|
||||
*/
|
||||
initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta
|
||||
// Threaded through `AppEditorHeader` to the `AutosaveIndicator`
|
||||
// popover so its "Reset to deployed" button can do the same thing
|
||||
// the load-time toast offers.
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Modal shown when the four route-level editors detect that the
|
||||
* remote state has moved on since the local autosave was captured —
|
||||
* either a teammate (or another tab) pushed a fresh "Save as draft"
|
||||
* via `DraftService` (`cause = 'draft'`), or the deployed version
|
||||
* changed (`cause = 'version'`).
|
||||
*
|
||||
* Sits above the per-browser `UserDraft` autosave layer and is
|
||||
* separate from the backend `DraftService` flow. The actual reset
|
||||
* actions live at each call site; this modal just owns the
|
||||
* "Load latest version" vs "Keep current draft" decision.
|
||||
*/
|
||||
import { classNames } from '$lib/utils'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { CornerDownLeft, RefreshCcw } from 'lucide-svelte'
|
||||
|
||||
// 'url' is temporary — used by /scripts/{add,edit}'s URL-hash sync block
|
||||
// while we wait for a future PR to replace that legacy behavior.
|
||||
type Cause = 'draft' | 'version' | 'url'
|
||||
|
||||
let {
|
||||
open = false,
|
||||
cause = 'version',
|
||||
onLoadLatest,
|
||||
onKeepDraft
|
||||
}: {
|
||||
open?: boolean
|
||||
/** What changed on the remote since the local draft was created. */
|
||||
cause?: Cause
|
||||
onLoadLatest: () => void | Promise<void>
|
||||
onKeepDraft: () => void | Promise<void>
|
||||
} = $props()
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!open) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
onLoadLatest()
|
||||
break
|
||||
case 'Escape':
|
||||
onKeepDraft()
|
||||
break
|
||||
}
|
||||
}
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 100 })
|
||||
}
|
||||
|
||||
const title = $derived(
|
||||
cause === 'draft'
|
||||
? 'A newer draft was saved on the server'
|
||||
: cause === 'url'
|
||||
? 'The URL contains a different script payload'
|
||||
: 'A newer version was deployed on the server'
|
||||
)
|
||||
const body = $derived(
|
||||
cause === 'draft'
|
||||
? "The editor is showing your local autosave. Someone else (or another tab) pushed a newer draft to the server while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
|
||||
: cause === 'url'
|
||||
? 'The current page URL encodes a script (e.g. from a Fork link or shared URL) that differs from your local autosave. Load from URL replaces your local draft; Keep current draft ignores the URL payload.'
|
||||
: "The editor is showing your local autosave. A newer version was deployed while you were editing — your copy is now behind. Load latest replaces what's on screen; Keep current leaves it alone."
|
||||
)
|
||||
const loadLabel = $derived(cause === 'url' ? 'Load from URL' : 'Load latest version')
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydowncapture={onKeyDown} />
|
||||
|
||||
{#if open}
|
||||
<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',
|
||||
open ? 'ease-out duration-300 opacity-100' : 'ease-in duration-200 opacity-0'
|
||||
)}
|
||||
></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={classNames(
|
||||
'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',
|
||||
open
|
||||
? 'ease-out duration-300 opacity-100 translate-y-0 sm:scale-100'
|
||||
: 'ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95'
|
||||
)}
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800/50"
|
||||
>
|
||||
<RefreshCcw class="text-blue-700 dark:text-blue-300" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-primary">{title}</h3>
|
||||
<p class="mt-2 text-sm text-secondary">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
<Button
|
||||
on:click={() => onLoadLatest()}
|
||||
color="dark"
|
||||
size="sm"
|
||||
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
|
||||
variant="accent"
|
||||
>
|
||||
<span class="min-w-20">{loadLabel}</span>
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => onKeepDraft()}
|
||||
variant="default"
|
||||
size="sm"
|
||||
shortCut={{ key: 'Esc', withoutModifier: true }}
|
||||
>
|
||||
Keep current draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -307,9 +307,6 @@ describe('global AI tools', () => {
|
||||
wsSpecific: true,
|
||||
resource_type: 'postgresql'
|
||||
})
|
||||
expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: '2026-05-22T09:30:00Z'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes variable drafts in the editor UserDraft shape', async () => {
|
||||
@@ -347,9 +344,6 @@ describe('global AI tools', () => {
|
||||
is_oauth: true,
|
||||
expires_at: '2026-06-22T09:30:00Z'
|
||||
})
|
||||
expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: '2026-05-22T09:30:00Z'
|
||||
})
|
||||
expect(localStorageSnapshot()).not.toContain('new-secret-token')
|
||||
})
|
||||
|
||||
@@ -662,9 +656,6 @@ describe('global AI tools', () => {
|
||||
content: 'new content',
|
||||
language: 'bun'
|
||||
})
|
||||
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 'deployed-hash'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
|
||||
@@ -694,9 +685,6 @@ describe('global AI tools', () => {
|
||||
description: 'deployed description',
|
||||
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
|
||||
})
|
||||
expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 42
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves editor schedule fields when writing over an existing schedule', async () => {
|
||||
@@ -849,9 +837,6 @@ describe('global AI tools', () => {
|
||||
policy: { execution_mode: 'publisher' },
|
||||
custom_path: 'report'
|
||||
})
|
||||
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
|
||||
remoteRev: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes local raw app drafts in read_workspace_item', async () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
} from '../shared'
|
||||
import type { ContextElement } from '../context'
|
||||
import { getDatatableTools } from '../datatableTools'
|
||||
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import {
|
||||
@@ -864,7 +864,6 @@ type AppMetadata = {
|
||||
|
||||
type LoadedAppDraftValue = {
|
||||
value: AppDraftValue
|
||||
meta?: UserDraftMeta
|
||||
}
|
||||
|
||||
function summarizeAppValue(value: AppDraftValue): AppMetadata {
|
||||
@@ -1005,12 +1004,6 @@ function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
|
||||
}
|
||||
}
|
||||
|
||||
function appDraftMeta(app: { versions?: number[] }): UserDraftMeta {
|
||||
return {
|
||||
remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
|
||||
const draft = getGlobalDraft(workspace, 'app', path)
|
||||
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
|
||||
@@ -1028,17 +1021,11 @@ async function loadAppDraftValue(path: string, workspace: string): Promise<Loade
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
const value = appSourceToDraftValue(app, app)
|
||||
return { value, meta: appDraftMeta(app) }
|
||||
return { value: appSourceToDraftValue(app, app) }
|
||||
}
|
||||
|
||||
function saveAppDraft(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
meta?: UserDraftMeta
|
||||
): WorkspaceItem {
|
||||
return saveGlobalAppDraft(workspace, path, value, meta)
|
||||
function saveAppDraft(workspace: string, path: string, value: AppDraftValue): WorkspaceItem {
|
||||
return saveGlobalAppDraft(workspace, path, value)
|
||||
}
|
||||
|
||||
type TriggerLike = { path: string; summary?: string | null }
|
||||
@@ -2207,13 +2194,7 @@ async function writeScriptDraft(
|
||||
content: args.content,
|
||||
language: args.language
|
||||
}
|
||||
UserDraft.setDraftAndMeta(
|
||||
'script',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: existing.hash },
|
||||
{ workspace }
|
||||
)
|
||||
UserDraft.save('script', storagePath, draft, { workspace })
|
||||
} else {
|
||||
const draft: NewScript = {
|
||||
path: args.path,
|
||||
@@ -2264,10 +2245,7 @@ async function writeFlowDraft(
|
||||
}
|
||||
UserDraft.save('flow', storagePath, draft, { workspace })
|
||||
} else if (backendExists) {
|
||||
const [existing, latestVersion] = await Promise.all([
|
||||
FlowService.getFlowByPath({ workspace, path: args.path }),
|
||||
FlowService.getFlowLatestVersion({ workspace, path: args.path })
|
||||
])
|
||||
const existing = await FlowService.getFlowByPath({ workspace, path: args.path })
|
||||
const draft: Flow = {
|
||||
...structuredClone(existing),
|
||||
path: args.path,
|
||||
@@ -2275,13 +2253,7 @@ async function writeFlowDraft(
|
||||
value,
|
||||
schema: draftValue.schema ?? existing.schema
|
||||
}
|
||||
UserDraft.setDraftAndMeta(
|
||||
'flow',
|
||||
storagePath,
|
||||
draft,
|
||||
{ remoteRev: latestVersion.id },
|
||||
{ workspace }
|
||||
)
|
||||
UserDraft.save('flow', storagePath, draft, { workspace })
|
||||
} else {
|
||||
const draft: Flow = {
|
||||
path: args.path,
|
||||
@@ -2379,11 +2351,10 @@ async function writeResourceDraft(args: CreateResource, ctx: WriteDraftCtx): Pro
|
||||
})
|
||||
} else if (backendExists) {
|
||||
const existing = await ResourceService.getResource({ workspace, path: args.path })
|
||||
UserDraft.setDraftAndMeta(
|
||||
UserDraft.save(
|
||||
'resource',
|
||||
args.path,
|
||||
createResourceToDraftState(args, resourceToDraftState(existing)),
|
||||
{ remoteRev: existing.edited_at },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
@@ -2416,11 +2387,10 @@ async function writeVariableDraft(args: CreateVariable, ctx: WriteDraftCtx): Pro
|
||||
path: args.path,
|
||||
decryptSecret: false
|
||||
})
|
||||
UserDraft.setDraftAndMeta(
|
||||
UserDraft.save(
|
||||
'variable',
|
||||
args.path,
|
||||
createVariableToDraftState(args, variableToDraftState(existing)),
|
||||
{ remoteRev: existing.edited_at },
|
||||
{ workspace }
|
||||
)
|
||||
} else {
|
||||
@@ -2819,9 +2789,9 @@ async function writeAppFile(
|
||||
content: `Writing ${target.filePath} to app "${args.path}"...`
|
||||
})
|
||||
|
||||
const { value, meta } = await loadAppDraftValue(args.path, workspace)
|
||||
const { value } = await loadAppDraftValue(args.path, workspace)
|
||||
value.files = { ...value.files, [target.filePath]: args.content }
|
||||
const stored = saveAppDraft(workspace, args.path, value, meta)
|
||||
const stored = saveAppDraft(workspace, args.path, value)
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Updated ${target.filePath} in app "${args.path}"`,
|
||||
@@ -2855,13 +2825,13 @@ async function deleteAppFile(
|
||||
content: `Deleting ${target.filePath} from app "${args.path}"...`
|
||||
})
|
||||
|
||||
const { value, meta } = await loadAppDraftValue(args.path, workspace)
|
||||
const { value } = await loadAppDraftValue(args.path, workspace)
|
||||
if (!(target.filePath in value.files)) {
|
||||
throw new Error(`Frontend file "${target.filePath}" not found in app "${args.path}".`)
|
||||
}
|
||||
const { [target.filePath]: _removed, ...remaining } = value.files
|
||||
value.files = remaining
|
||||
const stored = saveAppDraft(workspace, args.path, value, meta)
|
||||
const stored = saveAppDraft(workspace, args.path, value)
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Removed ${target.filePath} from app "${args.path}"`,
|
||||
@@ -2905,7 +2875,7 @@ async function patchAppFile(
|
||||
content: `Patching ${target.filePath} in app "${path}"...`
|
||||
})
|
||||
|
||||
const { value, meta } = await loadAppDraftValue(path, workspace)
|
||||
const { value } = await loadAppDraftValue(path, workspace)
|
||||
let currentContent: string
|
||||
let runnable: PersistedRunnable | undefined
|
||||
|
||||
@@ -2945,7 +2915,7 @@ async function patchAppFile(
|
||||
}
|
||||
}
|
||||
|
||||
const stored = saveAppDraft(workspace, path, value, meta)
|
||||
const stored = saveAppDraft(workspace, path, value)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Patched ${target.filePath} in app "${path}"`,
|
||||
result: 'Saved to local storage'
|
||||
@@ -2982,12 +2952,12 @@ async function writeAppRunnable(
|
||||
content: `Writing runnable "${key}" to app "${path}"...`
|
||||
})
|
||||
|
||||
const { value, meta } = await loadAppDraftValue(path, workspace)
|
||||
const { value } = await loadAppDraftValue(path, workspace)
|
||||
const existing = value.runnables[key] as PersistedRunnable | undefined
|
||||
const persisted = buildPersistedRunnable(input, existing)
|
||||
value.runnables = { ...value.runnables, [key]: persisted }
|
||||
await recomputeAppPolicy(value)
|
||||
const stored = saveAppDraft(workspace, path, value, meta)
|
||||
const stored = saveAppDraft(workspace, path, value)
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Updated runnable "${key}" in app "${path}"`,
|
||||
@@ -3014,14 +2984,14 @@ async function deleteAppRunnable(
|
||||
content: `Removing runnable "${key}" from app "${path}"...`
|
||||
})
|
||||
|
||||
const { value, meta } = await loadAppDraftValue(path, workspace)
|
||||
const { value } = await loadAppDraftValue(path, workspace)
|
||||
if (!(key in value.runnables)) {
|
||||
throw new Error(`Backend runnable "${key}" not found in app "${path}".`)
|
||||
}
|
||||
const { [key]: _removed, ...remaining } = value.runnables
|
||||
value.runnables = remaining
|
||||
await recomputeAppPolicy(value)
|
||||
const stored = saveAppDraft(workspace, path, value, meta)
|
||||
const stored = saveAppDraft(workspace, path, value)
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Removed runnable "${key}" from app "${path}"`,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
UserDraft,
|
||||
type UserDraftEntry,
|
||||
type UserDraftItemKind,
|
||||
type UserDraftMeta
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
import {
|
||||
getWorkspaceItemKey,
|
||||
type AppDraftValue,
|
||||
@@ -349,16 +344,11 @@ export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
|
||||
export function saveGlobalAppDraft(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
meta?: UserDraftMeta
|
||||
value: AppDraftValue
|
||||
): WorkspaceItem {
|
||||
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
|
||||
const normalized = normalizeAppDraftValue(value)
|
||||
if (meta) {
|
||||
UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace })
|
||||
} else {
|
||||
UserDraft.save('raw_app', storagePath, normalized, { workspace })
|
||||
}
|
||||
UserDraft.save('raw_app', storagePath, normalized, { workspace })
|
||||
const stored = getGlobalDraft(workspace, 'app', path)
|
||||
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
|
||||
return stored
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import { untrack } from 'svelte'
|
||||
import { UserDraft, localDraftDiffers, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
|
||||
type Cfg = Record<string, any>
|
||||
|
||||
/**
|
||||
* JSON round-trip normalization. Freshly-built config objects (e.g. a
|
||||
* trigger editor's `getXConfig()`) keep `undefined`-valued keys, so a
|
||||
* raw `deepEqual` reports spurious differences (`{ a: undefined }` ≠
|
||||
* `{}`). Normalize BOTH sides through the same round-trip before
|
||||
* comparing. Returns the input unchanged if it can't be serialized.
|
||||
*/
|
||||
function normalizeForCompare<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as V
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `a` differs meaningfully from `b` after JSON-normalizing
|
||||
* both sides. Returns `false` when `a` is nullish (treats "no draft"
|
||||
* as "no divergence"). Typed as a guard: a `true` result narrows `a`
|
||||
* to non-nullish `V`.
|
||||
*/
|
||||
function cfgDiffers<V>(a: V | undefined | null, b: V | undefined): a is V {
|
||||
if (a === undefined || a === null) return false
|
||||
return !deepEqual(normalizeForCompare(a), normalizeForCompare(b))
|
||||
}
|
||||
|
||||
export interface TriggerDraftSyncOptions {
|
||||
/** UserDraft item kind for this trigger, e.g. `'trigger_postgres'`. */
|
||||
itemKind: UserDraftItemKind
|
||||
@@ -66,7 +94,7 @@ export interface TriggerDraftSync {
|
||||
* - **persist-effect**: writes form edits back through the handle, dropping
|
||||
* the draft when the form is back at the deployed baseline.
|
||||
*
|
||||
* Both effect bodies are `untrack`ed and gated by `localDraftDiffers`
|
||||
* Both effect bodies are `untrack`ed and gated by `cfgDiffers`
|
||||
* idempotence so they can't feed back into each other. Must be called once
|
||||
* during component init (it registers `useMany` + two `$effect`s).
|
||||
*/
|
||||
@@ -85,7 +113,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
const hasDraft = $derived(
|
||||
!opts.drawerLoading() &&
|
||||
opts.deployed() != null &&
|
||||
localDraftDiffers(opts.getCfg() as Cfg, opts.deployed() as Cfg)
|
||||
cfgDiffers(opts.getCfg() as Cfg, opts.deployed() as Cfg)
|
||||
)
|
||||
|
||||
function discard(path: string, fallback: Cfg | undefined): void {
|
||||
@@ -99,7 +127,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
const d = handle?.draft
|
||||
if (opts.drawerLoading() || d == null) return
|
||||
untrack(() => {
|
||||
if (localDraftDiffers(d, opts.getCfg() as Cfg)) {
|
||||
if (cfgDiffers(d, opts.getCfg() as Cfg)) {
|
||||
void opts.applyCfg(d)
|
||||
}
|
||||
})
|
||||
@@ -115,8 +143,8 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
const h = handle
|
||||
if (!h) return
|
||||
const deployed = opts.deployed()
|
||||
if (localDraftDiffers(cfg, deployed)) {
|
||||
if (localDraftDiffers(cfg, h.draft)) h.draft = cfg
|
||||
if (cfgDiffers(cfg, deployed)) {
|
||||
if (cfgDiffers(cfg, h.draft)) h.draft = cfg
|
||||
} else {
|
||||
discard(opts.path(), deployed)
|
||||
}
|
||||
@@ -138,7 +166,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft
|
||||
},
|
||||
async maybeRestore() {
|
||||
const d = handle?.draft
|
||||
if (!localDraftDiffers(d, opts.getCfg() as Cfg)) return
|
||||
if (!cfgDiffers(d, opts.getCfg() as Cfg)) return
|
||||
// Overlay the local autosave on the just-loaded backend config.
|
||||
await opts.applyCfg(d)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { workspaceStore } from './stores'
|
||||
import { readFieldsRecursively } from './utils'
|
||||
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
|
||||
@@ -54,53 +53,8 @@ export type UserDraftListOptions = UserDraftOptions & {
|
||||
itemKinds?: readonly UserDraftItemKind[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A single (kind, path, workspace) tuple that `useMany` should hold a
|
||||
* handle for. The shape mirrors `use()`'s arguments, just bundled into
|
||||
* one object so a getter can return a list of them.
|
||||
*/
|
||||
export type UserDraftSpec<V = unknown> = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
workspace?: string
|
||||
/**
|
||||
* Value the handle reports when the entry is first acquired and no
|
||||
* autosave is persisted. Seeded into the in-memory cell on acquire and
|
||||
* swallowed by the sync effect so it never POSTs — the user's first
|
||||
* real edit is the first synced write. An entry that already exists
|
||||
* (refcount > 0, e.g. another live handle) keeps its current value; the
|
||||
* default is ignored in that case (an existing autosave always wins).
|
||||
*/
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the remote item's freshness at the moment the local draft
|
||||
* was seeded. Used by editor routes to detect that the remote has moved
|
||||
* on since the user last saw it.
|
||||
*
|
||||
* - `remoteRev`: the deployed version's id/hash/timestamp at draft load.
|
||||
* - `remoteDraftRev`: the DB-draft `created_at` at draft load, only set
|
||||
* for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`).
|
||||
*
|
||||
* Meta is in-memory only. It's seeded by the editor on every load (from
|
||||
* the backend response) and lost on page reload — which is fine because
|
||||
* the editor reseeds it.
|
||||
*/
|
||||
export type UserDraftMeta = {
|
||||
remoteRev?: string | number
|
||||
remoteDraftRev?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cell shape. The value + meta are wrapped together so a single
|
||||
* reactive assignment carries both, which keeps the DB sync effect's
|
||||
* "did anything change" comparison stable.
|
||||
*/
|
||||
type StoredDraft<V> = { value: V } & UserDraftMeta
|
||||
|
||||
type DraftState<V> = {
|
||||
val: StoredDraft<V> | undefined
|
||||
val: V | undefined
|
||||
}
|
||||
|
||||
type DraftEntry = {
|
||||
@@ -140,7 +94,6 @@ export type UserDraftEntry<V = unknown> = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
value: V | undefined
|
||||
meta: UserDraftMeta
|
||||
}
|
||||
|
||||
export type LiveEditorDraft = {
|
||||
@@ -182,46 +135,6 @@ function resolveWorkspace(opts?: UserDraftOptions): string {
|
||||
return ws
|
||||
}
|
||||
|
||||
function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const out: StoredDraft<V> = { value }
|
||||
if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev
|
||||
if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev
|
||||
return out
|
||||
}
|
||||
|
||||
function unwrap<V>(stored: StoredDraft<V> | undefined): V | undefined {
|
||||
return stored?.value
|
||||
}
|
||||
|
||||
function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
|
||||
if (!stored) return {}
|
||||
const meta: UserDraftMeta = {}
|
||||
if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev
|
||||
if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the rev metadata recorded against the local draft to the current
|
||||
* backend revs. Returns the staleness cause, or `null` when the local draft
|
||||
* is still based on the latest backend state we know about.
|
||||
*/
|
||||
export type UserDraftStalenessCause = 'draft' | 'version'
|
||||
|
||||
export function checkStaleness(
|
||||
meta: UserDraftMeta,
|
||||
currentRev: string | number | undefined,
|
||||
currentDraftRev?: string | number | undefined
|
||||
): UserDraftStalenessCause | null {
|
||||
if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null
|
||||
if (meta.remoteDraftRev !== currentDraftRev) {
|
||||
return currentDraftRev !== undefined ? 'draft' : 'version'
|
||||
}
|
||||
if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version'
|
||||
return null
|
||||
}
|
||||
|
||||
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
||||
return `${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
@@ -246,58 +159,6 @@ function snapshotDraftValue<V>(value: V | undefined): V | undefined {
|
||||
export type UserDraftHandle<V> = {
|
||||
get draft(): V | undefined
|
||||
set draft(value: V | undefined)
|
||||
/**
|
||||
* Read the rev metadata stored alongside the current draft. Empty
|
||||
* object if the entry has no draft or no rev was ever recorded.
|
||||
*/
|
||||
get meta(): UserDraftMeta
|
||||
/**
|
||||
* Set value AND rev metadata in one write. Later `draft = X` writes
|
||||
* preserve the rev metadata.
|
||||
*/
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void
|
||||
/**
|
||||
* Update rev metadata without touching the value. The `{ force }`
|
||||
* option is preserved for source compatibility but is now a no-op
|
||||
* (there is no localStorage layer to write synchronously through).
|
||||
*/
|
||||
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON round-trip normalization. Freshly-built config objects (e.g. a
|
||||
* trigger editor's `getXConfig()`) keep `undefined`-valued keys, so a
|
||||
* raw `deepEqual` reports spurious differences (`{ a: undefined }` ≠
|
||||
* `{}`). Normalize BOTH sides through the same round-trip before
|
||||
* comparing. Returns the input unchanged if it can't be serialized.
|
||||
*/
|
||||
export function normalizeForCompare<V>(value: V | undefined): V | undefined {
|
||||
if (value === undefined) return undefined
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as V
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current draft differs meaningfully from a freshly-built
|
||||
* `currentConfig`. Editor restore guards use this to decide whether to
|
||||
* overlay the draft and toast.
|
||||
*
|
||||
* Returns `false` when there is no draft. Normalizes both sides (see
|
||||
* `normalizeForCompare`) so a draft that round-trips equal to the
|
||||
* deployed config is correctly treated as "no meaningful draft".
|
||||
*
|
||||
* Typed as a guard: a `true` result narrows `localDraft` to non-nullish
|
||||
* `V`.
|
||||
*/
|
||||
export function localDraftDiffers<V>(
|
||||
localDraft: V | undefined | null,
|
||||
currentConfig: V
|
||||
): localDraft is V {
|
||||
if (localDraft === undefined || localDraft === null) return false
|
||||
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
|
||||
}
|
||||
|
||||
export const UserDraft = {
|
||||
@@ -306,12 +167,10 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
// Update the reactive cell — preserves any rev meta the
|
||||
// editor had seeded earlier. The DB sync rides on the
|
||||
// reactive effect in `acquireEntry`, which observes this
|
||||
// write and POSTs it to the syncer.
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
entry.state.val = wrap(value, extractMeta(current))
|
||||
// Update the reactive cell. The DB sync rides on the reactive
|
||||
// effect in `acquireEntry`, which observes this write and
|
||||
// POSTs it to the syncer.
|
||||
entry.state.val = value
|
||||
} else {
|
||||
// No live handle: push directly to the syncer. The next time
|
||||
// an editor mounts for this (workspace, kind, path) it will
|
||||
@@ -320,34 +179,10 @@ export const UserDraft = {
|
||||
}
|
||||
},
|
||||
|
||||
setDraftAndMeta<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V | undefined,
|
||||
meta: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (entry) {
|
||||
entry.state.val = wrap(value, meta)
|
||||
return
|
||||
}
|
||||
// No live handle and `save_draft` requires a value — skip the
|
||||
// sync on `undefined` (delete-via-static-write), which the route
|
||||
// can't represent. Use `discard` for that path.
|
||||
if (value !== undefined) {
|
||||
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value })
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the current draft value from the in-memory cell. Returns
|
||||
* `undefined` when no editor has mounted a handle for this
|
||||
* `(workspace, kind, path)` in this tab — UserDraft no longer
|
||||
* persists anywhere local, so loading is the editor's job (fetch
|
||||
* via `get_draft=true` and seed via `setDraftAndMeta`).
|
||||
* `(workspace, kind, path)` in this tab.
|
||||
*/
|
||||
get<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
@@ -358,41 +193,7 @@ export const UserDraft = {
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (!entry) return undefined
|
||||
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the rev metadata without touching the value. No-op when no
|
||||
* live entry exists (there's no off-cell place to record meta now).
|
||||
*/
|
||||
saveMeta(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
meta: UserDraftMeta,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (!entry) return
|
||||
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
|
||||
if (current === undefined) return
|
||||
// Meta-only writes shouldn't fire a sync — the DB doesn't store
|
||||
// rev meta and an empty POST to save_draft is wasteful.
|
||||
entry.skipNextSync = true
|
||||
entry.state.val = wrap(current.value, meta)
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the rev metadata for the entry. Returns an empty object if
|
||||
* there is no live entry. Useful for staleness checks.
|
||||
*/
|
||||
getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta {
|
||||
const ws = resolveWorkspace(opts)
|
||||
const mk = mapKey(ws, itemKind, path)
|
||||
const entry = entries.get(mk)
|
||||
if (!entry) return {}
|
||||
return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
|
||||
return snapshotDraftValue(entry.state.val as V | undefined)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -464,10 +265,9 @@ export const UserDraft = {
|
||||
},
|
||||
|
||||
/**
|
||||
* List currently-mounted live entries for `workspace`. Without the
|
||||
* localStorage layer, "list" is meaningful only for in-tab entries —
|
||||
* for a workspace-wide view across sessions, call
|
||||
* `DraftService.listDrafts` instead.
|
||||
* List currently-mounted live entries for `workspace`. Limited to
|
||||
* in-tab handles — for a workspace-wide view across sessions, call
|
||||
* `DraftService` directly.
|
||||
*/
|
||||
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
|
||||
const ws = resolveWorkspace(opts)
|
||||
@@ -475,14 +275,13 @@ export const UserDraft = {
|
||||
const out: UserDraftEntry<V>[] = []
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
|
||||
const stored = untrack(() => entry.state.val as StoredDraft<V> | undefined)
|
||||
if (stored === undefined) continue
|
||||
const val = untrack(() => entry.state.val as V | undefined)
|
||||
if (val === undefined) continue
|
||||
out.push({
|
||||
workspace: entry.workspace,
|
||||
itemKind: entry.itemKind,
|
||||
path: entry.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored)
|
||||
value: snapshotDraftValue(val)
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -542,7 +341,7 @@ export const UserDraft = {
|
||||
const safeFallback = snapshotDraftValue(fallback)
|
||||
if (entry) {
|
||||
entry.skipNextSync = true
|
||||
entry.state.val = wrap(safeFallback) as StoredDraft<unknown> | undefined
|
||||
entry.state.val = safeFallback
|
||||
}
|
||||
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
|
||||
},
|
||||
@@ -564,7 +363,21 @@ export const UserDraft = {
|
||||
return handles[0]
|
||||
},
|
||||
|
||||
useMany<V = unknown>(getSpecs: () => UserDraftSpec<V>[]): UserDraftHandle<V>[] {
|
||||
useMany<V = unknown>(
|
||||
getSpecs: () => {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
workspace?: string
|
||||
/**
|
||||
* Value the entry's cell is seeded with on first acquire. Swallowed
|
||||
* by the syncer's seed guard so it never POSTs — the user's first
|
||||
* real edit is the first synced write. An entry that already exists
|
||||
* (refcount > 0, e.g. another live handle) keeps its current value;
|
||||
* the default is ignored in that case.
|
||||
*/
|
||||
defaultValue?: V
|
||||
}[]
|
||||
): UserDraftHandle<V>[] {
|
||||
// Reactive handles array, reconciled against the latest
|
||||
// `getSpecs()` output. Indices line up with the spec array.
|
||||
// Handles for the same `(workspace, kind, path)` tuple are
|
||||
@@ -645,52 +458,39 @@ function acquireEntry(
|
||||
return
|
||||
}
|
||||
// Seed the cell with the caller's `defaultValue` (deep-cloned so the
|
||||
// cell owns its copy and the caller's baseline can't alias it). This is
|
||||
// how editors report the deployed/draft state until the user edits —
|
||||
// the sync effect treats this first write as the seed and never POSTs
|
||||
// it (see `lastSerialized`/`skipNextWrite` below).
|
||||
const seed =
|
||||
defaultValue !== undefined
|
||||
? (wrap(snapshotDraftValue(defaultValue)) as StoredDraft<unknown> | undefined)
|
||||
: undefined
|
||||
// cell owns its copy). The seed is treated as the first observable
|
||||
// write and swallowed by `skipNextWrite` below — it never POSTs.
|
||||
const seed = defaultValue !== undefined ? snapshotDraftValue(defaultValue) : undefined
|
||||
// `$effect.root` gives the entry its own scope, disposed only by
|
||||
// `releaseEntry`. Without that, the sync `$effect` would parent to
|
||||
// `useMany`'s reconcile effect and be torn down on the next
|
||||
// reconcile.
|
||||
let stateRef: DraftState<unknown> | undefined
|
||||
const destroyRoot = $effect.root(() => {
|
||||
const cell = $state<{ val: StoredDraft<unknown> | undefined }>({ val: seed })
|
||||
stateRef = cell
|
||||
const cell = $state<{ val: unknown }>({ val: seed })
|
||||
stateRef = cell as DraftState<unknown>
|
||||
// Mirror every observable change of `cell.val` to the DB
|
||||
// syncer. Reading `cell.val` alone only subscribes to the proxy
|
||||
// root, so deep mutations (`handle.draft.content = '...'`)
|
||||
// would slip past; `readFieldsRecursively` walks the value so
|
||||
// the effect re-fires on nested writes too.
|
||||
//
|
||||
// `lastSerialized` + `skipNextWrite` mirror the dedup pattern
|
||||
// useLocalStorageValue used to have for `saveInitialValue=false`:
|
||||
// the effect ignores no-op `val` updates, and treats the FIRST
|
||||
// observable change after mount as the seed/restore (no sync).
|
||||
// That matches the editor's UX where landing on `?new_draft`
|
||||
// or seeding the deployed baseline shouldn't fire a POST until
|
||||
// the user actually edits something.
|
||||
// `lastSerialized` + `skipNextWrite` dedup no-op `val` updates
|
||||
// and treat the FIRST observable change after mount as the
|
||||
// seed/restore (no sync). That matches the editor's UX where
|
||||
// landing on `?new_draft` or seeding the deployed baseline
|
||||
// shouldn't fire a POST until the user actually edits.
|
||||
//
|
||||
// `stored === undefined` is the delete signal — the server
|
||||
// `cell.val === undefined` is the delete signal — the server
|
||||
// route accepts `value: null` for that. `skipNextSync` lets
|
||||
// callers that already POSTed (e.g. `discard`, `remove`,
|
||||
// `saveMeta`) suppress a duplicate fire from their own
|
||||
// reactive write.
|
||||
// Start at `undefined` even when the cell was seeded above: that way
|
||||
// the seed is the FIRST observable change the effect sees and gets
|
||||
// swallowed by `skipNextWrite`, so seeding the deployed/draft
|
||||
// baseline never POSTs. The user's first real edit is then the first
|
||||
// synced write.
|
||||
// callers that already POSTed (e.g. `discard`, `remove`)
|
||||
// suppress a duplicate fire from their own reactive write.
|
||||
let lastSerialized: string | undefined = undefined
|
||||
let skipNextWrite = true
|
||||
$effect(() => {
|
||||
const stored = cell.val
|
||||
if (stored !== undefined) readFieldsRecursively(stored.value)
|
||||
const next = stored === undefined ? undefined : JSON.stringify(stored)
|
||||
const val = cell.val
|
||||
if (val !== undefined) readFieldsRecursively(val)
|
||||
const next = val === undefined ? undefined : JSON.stringify(val)
|
||||
if (next === lastSerialized) return
|
||||
lastSerialized = next
|
||||
if (skipNextWrite) {
|
||||
@@ -711,7 +511,7 @@ function acquireEntry(
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
value: stored === undefined ? null : stored.value
|
||||
value: val === undefined ? null : val
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -732,13 +532,13 @@ function acquireEntry(
|
||||
// isn't invoked. Unreachable in production (Svelte runs it
|
||||
// synchronously). The fallback cell has no sync effect, so writes
|
||||
// in tests stay in-memory.
|
||||
const fallback = $state<{ val: StoredDraft<unknown> | undefined }>({ val: seed })
|
||||
const fallback = $state<{ val: unknown }>({ val: seed })
|
||||
entries.set(mk, {
|
||||
count: 1,
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
state: fallback,
|
||||
state: fallback as DraftState<unknown>,
|
||||
skipNextSync: false,
|
||||
syncSuspended: pendingSuspensions.delete(mk)
|
||||
})
|
||||
@@ -769,38 +569,11 @@ function makeHandle<V>(
|
||||
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
|
||||
return {
|
||||
get draft(): V | undefined {
|
||||
return unwrap(stateOf()?.val as StoredDraft<V> | undefined)
|
||||
return stateOf()?.val as V | undefined
|
||||
},
|
||||
set draft(value: V | undefined) {
|
||||
// Preserve existing rev metadata on a value edit. `untrack`
|
||||
// the read: callers often set this from inside a `$effect`
|
||||
// mirroring `$state` into the handle; a tracked read would
|
||||
// subscribe that effect to the cell it's about to write
|
||||
// (self-loop → effect_update_depth_exceeded).
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
state.val = wrap(value, extractMeta(current))
|
||||
},
|
||||
get meta(): UserDraftMeta {
|
||||
return extractMeta(stateOf()?.val as StoredDraft<unknown> | undefined)
|
||||
},
|
||||
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void {
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
state.val = wrap(value, meta)
|
||||
},
|
||||
setMeta(meta: UserDraftMeta, _opts?: { force?: boolean }): void {
|
||||
// `force` was useful when there was a localStorage layer to
|
||||
// write through synchronously. Kept in the signature for
|
||||
// source compatibility but ignored now.
|
||||
const state = stateOf()
|
||||
if (!state) return
|
||||
const current = untrack(() => state.val as StoredDraft<V> | undefined)
|
||||
if (current === undefined) return
|
||||
const entry = entries.get(mk)
|
||||
if (entry) entry.skipNextSync = true
|
||||
state.val = wrap(current.value, meta)
|
||||
if (state) state.val = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* "Restored from local storage" toast, shown when an editor reopens on a
|
||||
* local autosave that differs from the backend. Owns only the wording and
|
||||
* which reset actions are offered; the reset side-effects live at each call
|
||||
* site (route-specific state).
|
||||
* Toast helpers shared by the editor routes when a per-user draft is loaded
|
||||
* from the server. Owns only the wording and reset action; the reset
|
||||
* side-effects live at each call site (route-specific state).
|
||||
*/
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
@@ -75,30 +74,6 @@ function armRestartOnFirstInteraction(
|
||||
const fallback = setTimeout(restart, 5000)
|
||||
}
|
||||
|
||||
export type RestoreFromLocalActions = {
|
||||
/** Drop the local autosave, apply the backend DB draft. Offered when `hasBackendDraft`. */
|
||||
onResetToSavedDraft?: () => void | Promise<void>
|
||||
/** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */
|
||||
onResetToDeployed?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Show the toast with up to two reset actions, gated by what the backend has. */
|
||||
export function notifyRestoredFromLocal(
|
||||
hasBackendDraft: boolean,
|
||||
hasDeployed: boolean,
|
||||
{ onResetToSavedDraft, onResetToDeployed }: RestoreFromLocalActions
|
||||
): void {
|
||||
const actions: Array<{ label: string; callback: () => void | Promise<void> }> = []
|
||||
if (hasBackendDraft && onResetToSavedDraft) {
|
||||
actions.push({ label: 'Reset to saved draft', callback: onResetToSavedDraft })
|
||||
}
|
||||
if (hasDeployed && onResetToDeployed) {
|
||||
actions.push({ label: 'Reset to deployed', callback: onResetToDeployed })
|
||||
}
|
||||
if (actions.length === 0) return
|
||||
sendUserToast('Restored from local storage', false, actions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when an editor mounts on a per-user draft fetched from the server
|
||||
* (the `get_draft=true` overlay returned `is_draft: true`).
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
|
||||
import { AppService, type AppWithLastVersion } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { cleanValueProperties, orderedJsonStringify } from '$lib/utils'
|
||||
import { replaceState } from '$app/navigation'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import DraftSyncConflictModal from '$lib/components/common/confirmationModal/DraftSyncConflictModal.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import OtherUsersDraftsModal, {
|
||||
@@ -18,8 +16,8 @@
|
||||
import { emptyApp } from '$lib/components/apps/editor/appUtils'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded, notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded } from '$lib/userDraftToast'
|
||||
|
||||
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
|
||||
let savedApp:
|
||||
@@ -42,48 +40,6 @@
|
||||
let isNewApp = $state(false)
|
||||
let otherDraftsUsers = $state<OtherDraftUser[]>([])
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: AppWithLastVersion & { value: any }; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
// Backend revs at the most recent `loadApp` — handed to AppEditor as
|
||||
// `initialRevs` so the very first local autosave persists with a meta
|
||||
// stamp. Without it the next reload's staleness check has nothing to
|
||||
// compare against and the first external deploy/draft slips through.
|
||||
let currentRevs = $state<UserDraftMeta | undefined>(undefined)
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
// `discard` (not `remove`) so the entry's in-memory state.val is
|
||||
// cleared synchronously. `redraw++` remounts AppEditor on the next
|
||||
// microtask, but Svelte may mount the new instance before the old
|
||||
// one's onDestroy releases its handle — the new instance would
|
||||
// then re-acquire the SAME entry whose state.val still has the
|
||||
// stale autosave, ignoring the just-emptied LS. Same reason every
|
||||
// "reset" path below uses discard.
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = pendingBaseline.revs
|
||||
app = pendingBaseline.baseline
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
UserDraft.saveMeta('app', path, pendingBaseline.revs)
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
/** Increments per `loadApp` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
@@ -138,7 +94,6 @@
|
||||
path: '',
|
||||
policy: emptyPolicy
|
||||
}
|
||||
currentRevs = {}
|
||||
return
|
||||
}
|
||||
let backendApp = await AppService.getAppByPath({
|
||||
@@ -213,52 +168,12 @@
|
||||
policy: backendApp_.policy,
|
||||
custom_path: backendApp_.custom_path
|
||||
}
|
||||
|
||||
const localDraftValue = UserDraft.get<App>('app', path)
|
||||
const previousMeta = UserDraft.getMeta('app', path)
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: backendApp.versions
|
||||
? backendApp.versions[backendApp.versions.length - 1]
|
||||
: undefined
|
||||
}
|
||||
currentRevs = newRevs
|
||||
if (
|
||||
localDraftValue != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendApp.value as any))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendApp, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
UserDraft.saveMeta('app', path, newRevs)
|
||||
}
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.discard('app', path, undefined)
|
||||
currentRevs = newRevs
|
||||
app = backendApp
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
UserDraft.discard('app', path, undefined)
|
||||
goto(`/apps/edit/${backendApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
app = { ...backendApp, value: localDraftValue }
|
||||
} else {
|
||||
// Local is missing or matches backend — wipe any stale entry so it
|
||||
// doesn't haunt the next session and use the backend value.
|
||||
if (localDraftValue != undefined) UserDraft.remove('app', path)
|
||||
app = backendApp
|
||||
}
|
||||
// Backend canonical: wipe the in-memory cell so AppEditor remounts
|
||||
// fresh from `backendApp.value`. The cell will be re-seeded by
|
||||
// AppEditor's mirror $effect; the first such write is swallowed
|
||||
// by `acquireEntry`'s seed guard so this load doesn't POST.
|
||||
UserDraft.discard('app', path, undefined)
|
||||
app = backendApp
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -306,12 +221,6 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if $workspaceStore && path}
|
||||
<DraftSyncConflictModal
|
||||
query={{ workspace: $workspaceStore, itemKind: 'app', path }}
|
||||
@@ -352,7 +261,6 @@
|
||||
{diffDrawer}
|
||||
version={app.versions ? app.versions[app.versions.length - 1] : undefined}
|
||||
newApp={isNewApp}
|
||||
initialRevs={currentRevs}
|
||||
replaceStateFn={(path) => replaceState(path, page.state)}
|
||||
gotoFn={(path, opt) => goto(path, opt)}
|
||||
onResetToDeployed={async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { AppService } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { cleanValueProperties, orderedJsonStringify, readFieldsRecursively } from '$lib/utils'
|
||||
import { readFieldsRecursively } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
@@ -13,14 +13,8 @@
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
localDraftDiffers,
|
||||
type UserDraftMeta
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded, notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded } from '$lib/userDraftToast'
|
||||
import DraftSyncConflictModal from '$lib/components/common/confirmationModal/DraftSyncConflictModal.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import OtherUsersDraftsModal, {
|
||||
@@ -107,36 +101,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline:
|
||||
| { baseline: RawAppDraft; backendSource: any; revs: UserDraftMeta }
|
||||
| undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, backendSource, revs } = pendingBaseline
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(baseline, revs)
|
||||
extractRawApp(backendSource)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
redraw++
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
draftHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
// Persist the bundle whenever any of the four pieces of state changes.
|
||||
$effect(() => {
|
||||
const currentFiles = files
|
||||
@@ -162,32 +126,6 @@
|
||||
} as RawAppDraft
|
||||
})
|
||||
|
||||
// Reflect an external UserDraft.save into the form. Idempotent; the
|
||||
// `!files` guard skips the reload window so it doesn't fight loadApp.
|
||||
$effect(() => {
|
||||
const d = draftHandle.draft
|
||||
const currentFiles = files
|
||||
if (d == null || !currentFiles) return
|
||||
untrack(() => {
|
||||
if (
|
||||
localDraftDiffers(d, {
|
||||
files: currentFiles,
|
||||
runnables,
|
||||
data,
|
||||
summary,
|
||||
policy,
|
||||
custom_path: savedApp?.custom_path
|
||||
})
|
||||
) {
|
||||
files = d.files
|
||||
runnables = d.runnables
|
||||
data = d.data
|
||||
summary = d.summary
|
||||
if (d.policy !== undefined) policy = d.policy
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function extractRawApp(app: any) {
|
||||
runnables = app.value.runnables
|
||||
// Support old formats and new format
|
||||
@@ -300,7 +238,7 @@
|
||||
path: page.params.path ?? '',
|
||||
draftOnly: backendApp.no_deployed,
|
||||
onResetToDeployed: async () => {
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
draftHandle.draft = undefined
|
||||
await loadApp({ getDraft: false })
|
||||
}
|
||||
})
|
||||
@@ -367,77 +305,12 @@
|
||||
policy: backendApp_.policy,
|
||||
custom_path: backendApp_.custom_path
|
||||
}
|
||||
|
||||
const backendSource: any = backendApp
|
||||
const localDraft = draftHandle.draft
|
||||
const previousMeta = draftHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: backendApp.versions
|
||||
? backendApp.versions[backendApp.versions.length - 1]
|
||||
: undefined
|
||||
}
|
||||
const backendBundle: RawAppDraft = {
|
||||
files: backendSource.value?.files ?? {},
|
||||
runnables: backendSource.value?.runnables ?? {},
|
||||
data:
|
||||
backendSource.value?.data ??
|
||||
(backendSource.value?.datatables
|
||||
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
|
||||
: { ...DEFAULT_DATA }),
|
||||
summary: backendSource.summary ?? '',
|
||||
policy: backendSource.policy ?? backendApp.policy,
|
||||
custom_path: backendSource.custom_path ?? backendApp.custom_path
|
||||
}
|
||||
|
||||
// Merge defaults from `backendBundle` first so a localDraft with a
|
||||
// missing key (e.g. legacy autosaves written before `policy` /
|
||||
// `custom_path` were added to the bundle) doesn't read as "user has
|
||||
// unsaved changes" and fire the restore toast on every open.
|
||||
const localBundle = localDraft != undefined ? { ...backendBundle, ...localDraft } : undefined
|
||||
if (
|
||||
localBundle != undefined &&
|
||||
orderedJsonStringify(cleanValueProperties(localBundle)) !==
|
||||
orderedJsonStringify(cleanValueProperties(backendBundle))
|
||||
) {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: backendBundle, backendSource, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
draftHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
extractRawApp(backendSource)
|
||||
redraw++
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
UserDraft.remove('raw_app', path)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadApp doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
await loadApp()
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
runnables = localBundle.runnables
|
||||
data = localBundle.data
|
||||
summary = localBundle.summary
|
||||
policy = localBundle.policy ?? backendApp.policy
|
||||
newPath = backendApp.path
|
||||
files = localBundle.files
|
||||
} else {
|
||||
if (localDraft != undefined) UserDraft.remove('raw_app', path)
|
||||
extractRawApp(backendSource)
|
||||
draftHandle.setDraftAndMeta(backendBundle, newRevs)
|
||||
}
|
||||
// Backend canonical: extract the (deployed+draft overlay) raw
|
||||
// app into the editor's local pieces. The bundle $effect above
|
||||
// re-mirrors them into `draftHandle.draft`; the first such
|
||||
// write is swallowed by `acquireEntry`'s seed guard so this
|
||||
// load doesn't POST.
|
||||
extractRawApp(backendApp)
|
||||
}
|
||||
|
||||
run(() => {
|
||||
@@ -460,7 +333,7 @@
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('raw_app', path)
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
draftHandle.draft = undefined
|
||||
goto(`/apps/edit/${savedApp.path}`)
|
||||
await loadApp()
|
||||
redraw++
|
||||
@@ -509,12 +382,6 @@
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if $workspaceStore && path}
|
||||
<DraftSyncConflictModal
|
||||
query={{ workspace: $workspaceStore, itemKind: 'raw_app', path }}
|
||||
@@ -560,7 +427,7 @@
|
||||
{diffDrawer}
|
||||
newApp={isNewApp}
|
||||
onResetToDeployed={async () => {
|
||||
draftHandle.setDraftAndMeta(undefined, {})
|
||||
draftHandle.draft = undefined
|
||||
await loadApp({ getDraft: false })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -4,19 +4,12 @@
|
||||
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
import { initialArgsStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
decodeState,
|
||||
emptySchema,
|
||||
orderedJsonStringify,
|
||||
type StateStore
|
||||
} from '$lib/utils'
|
||||
import { decodeState, emptySchema, type StateStore } from '$lib/utils'
|
||||
import { initFlow } from '$lib/components/flows/flowStore.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import DraftSyncConflictModal from '$lib/components/common/confirmationModal/DraftSyncConflictModal.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import OtherUsersDraftsModal, {
|
||||
@@ -27,13 +20,8 @@
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
|
||||
import { page } from '$app/state'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
type UserDraftMeta,
|
||||
type UserDraftHandle
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded, notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded } from '$lib/userDraftToast'
|
||||
|
||||
let version: undefined | number = $state(undefined)
|
||||
|
||||
@@ -80,15 +68,6 @@
|
||||
set draft(value) {
|
||||
const handle = flowHandles[0]
|
||||
if (handle) handle.draft = value
|
||||
},
|
||||
get meta() {
|
||||
return flowHandles[0]?.meta ?? {}
|
||||
},
|
||||
setDraftAndMeta(value, meta) {
|
||||
flowHandles[0]?.setDraftAndMeta(value, meta)
|
||||
},
|
||||
setMeta(meta, opts) {
|
||||
flowHandles[0]?.setMeta(meta, opts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,33 +104,6 @@
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote has moved on since
|
||||
// the local autosave was written.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: Flow; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(baseline, revs)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
flowHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
|
||||
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
|
||||
let loadedFromHistoryFromUrl:
|
||||
@@ -224,7 +176,7 @@
|
||||
edited_by: ''
|
||||
} as unknown as Flow
|
||||
savedFlow = structuredClone(empty)
|
||||
flowHandle.setDraftAndMeta(empty, {})
|
||||
flowHandle.draft = empty
|
||||
flow = empty
|
||||
await initFlow(flow, flowStore, flowStateStore)
|
||||
if (tok !== loadFlowToken) return
|
||||
@@ -279,7 +231,7 @@
|
||||
path: page.params.path ?? '',
|
||||
draftOnly: backendFlow.no_deployed,
|
||||
onResetToDeployed: async () => {
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
flowHandle.draft = undefined
|
||||
await loadFlow({ getDraft: false })
|
||||
}
|
||||
})
|
||||
@@ -304,53 +256,12 @@
|
||||
const renderedDraftPath = (effectiveFlow as any).draft_path as string | undefined
|
||||
if (renderedDraftPath) flowInitialPath = renderedDraftPath
|
||||
|
||||
const localDraft = flowHandle.draft
|
||||
const previousMeta = flowHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: v
|
||||
}
|
||||
|
||||
if (localDraft != undefined) {
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
const backendClean = cleanValueProperties(effectiveFlow)
|
||||
if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) {
|
||||
// Local matches backend exactly — silently drop the autosave.
|
||||
flow = effectiveFlow
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(effectiveFlow, newRevs)
|
||||
} else {
|
||||
flow = localDraft
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
pendingBaseline = { baseline: effectiveFlow, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry — backfill meta so the next load can detect staleness.
|
||||
flowHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(effectiveFlow, newRevs)
|
||||
loadFlow()
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
// UserDraft.remove only clears localStorage. Drop the
|
||||
// entry's in-memory state too so loadFlow doesn't re-read
|
||||
// the stale autosave and re-fire the same toast.
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
loadFlow()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flow = effectiveFlow
|
||||
flowHandle.setDraftAndMeta(effectiveFlow, newRevs)
|
||||
}
|
||||
// Backend canonical: overwrite the in-memory cell with the
|
||||
// effective (deployed+draft overlay) flow. The first cell write
|
||||
// after `acquireEntry` is swallowed by the syncer's seed guard,
|
||||
// so this load doesn't POST.
|
||||
flow = effectiveFlow
|
||||
flowHandle.draft = effectiveFlow
|
||||
|
||||
flowBuilder?.setDraftTriggers(undefined)
|
||||
|
||||
@@ -397,7 +308,7 @@
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('flow', flowDraftPath)
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
flowHandle.draft = undefined
|
||||
goto(`/flows/edit/${savedFlow.path}`)
|
||||
loadFlow()
|
||||
}
|
||||
@@ -406,12 +317,6 @@
|
||||
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} isFlow />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if $workspaceStore && flowDraftPath}
|
||||
<DraftSyncConflictModal
|
||||
query={{ workspace: $workspaceStore, itemKind: 'flow', path: flowDraftPath }}
|
||||
@@ -450,7 +355,7 @@
|
||||
loadFlow()
|
||||
}}
|
||||
onResetToDeployed={async () => {
|
||||
flowHandle.setDraftAndMeta(undefined, {})
|
||||
flowHandle.draft = undefined
|
||||
await loadFlow({ getDraft: false })
|
||||
}}
|
||||
onNavigate={(item) => goto(editPathFor(item))}
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
import { initialArgsStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
|
||||
import { cleanValueProperties, emptySchema, orderedJsonStringify } from '$lib/utils'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
|
||||
import OtherUsersDraftsModal, {
|
||||
type OtherDraftUser
|
||||
} from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
|
||||
@@ -17,13 +16,8 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import {
|
||||
UserDraft,
|
||||
checkStaleness,
|
||||
type UserDraftMeta,
|
||||
type UserDraftHandle
|
||||
} from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded, notifyRestoredFromLocal } from '$lib/userDraftToast'
|
||||
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { notifyDraftLoaded } from '$lib/userDraftToast'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import DraftSyncConflictModal from '$lib/components/common/confirmationModal/DraftSyncConflictModal.svelte'
|
||||
|
||||
@@ -55,15 +49,6 @@
|
||||
set draft(value) {
|
||||
const handle = scriptHandles[0]
|
||||
if (handle) handle.draft = value
|
||||
},
|
||||
get meta() {
|
||||
return scriptHandles[0]?.meta ?? {}
|
||||
},
|
||||
setDraftAndMeta(value, meta) {
|
||||
scriptHandles[0]?.setDraftAndMeta(value, meta)
|
||||
},
|
||||
setMeta(meta, opts) {
|
||||
scriptHandles[0]?.setMeta(meta, opts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,43 +87,6 @@
|
||||
|
||||
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(undefined)
|
||||
|
||||
// Local-draft staleness modal: opened when the remote (deployed or DB
|
||||
// draft) has moved on since the user's autosave was created.
|
||||
let staleModalOpen = $state(false)
|
||||
let staleModalCause = $state<'draft' | 'version'>('version')
|
||||
let pendingBaseline: { baseline: EditableScript; revs: UserDraftMeta } | undefined = undefined
|
||||
|
||||
function applyBaseline(baseline: EditableScript): void {
|
||||
initialPath = baseline.path
|
||||
scriptBuilder?.setDraftTriggers(baseline.draft_triggers)
|
||||
scriptBuilder?.setCode(baseline.content)
|
||||
if (baseline['primary_schedule']) {
|
||||
savedPrimarySchedule = baseline['primary_schedule']
|
||||
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
|
||||
}
|
||||
}
|
||||
|
||||
function onStaleLoadLatest(): void {
|
||||
if (!pendingBaseline) {
|
||||
staleModalOpen = false
|
||||
return
|
||||
}
|
||||
const { baseline, revs } = pendingBaseline
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(baseline, revs)
|
||||
applyBaseline(baseline)
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
function onStaleKeepDraft(): void {
|
||||
if (pendingBaseline) {
|
||||
scriptHandle.setMeta(pendingBaseline.revs, { force: true })
|
||||
}
|
||||
pendingBaseline = undefined
|
||||
staleModalOpen = false
|
||||
}
|
||||
|
||||
/** Increments per `loadScript` call. Stale loads (e.g. when picker
|
||||
* navigation races a draft-discard reload) bail at the next checkpoint
|
||||
* after their captured token no longer matches. */
|
||||
@@ -159,12 +107,12 @@
|
||||
// metadata drawer on mount. Strip the single-use flag last.
|
||||
if (page.url.searchParams.get('new_draft') === 'true') {
|
||||
// Suspend autosave for the whole new-draft bootstrap: the seed
|
||||
// `setDraftAndMeta` AND ScriptBuilder's `initContent` (which
|
||||
// fills `script.content` from a template) are both
|
||||
// programmatic writes that shouldn't appear on the server as
|
||||
// the user's first edit. ScriptBuilder lifts the suspension
|
||||
// in `initContent`'s `.finally`; this overlap is harmless
|
||||
// (both calls set the same flag).
|
||||
// AND ScriptBuilder's `initContent` (which fills
|
||||
// `script.content` from a template) are both programmatic
|
||||
// writes that shouldn't appear on the server as the user's
|
||||
// first edit. ScriptBuilder lifts the suspension in
|
||||
// `initContent`'s `.finally`; this overlap is harmless (both
|
||||
// calls set the same flag).
|
||||
UserDraft.stopSync('script', draftPath)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('new_draft')
|
||||
@@ -190,7 +138,7 @@
|
||||
} as unknown as EditableScript
|
||||
initialPath = ''
|
||||
savedScript = structuredClone(empty)
|
||||
scriptHandle.setDraftAndMeta(empty, {})
|
||||
scriptHandle.draft = empty
|
||||
fullyLoaded = true
|
||||
renderEditor = true
|
||||
return
|
||||
@@ -232,7 +180,7 @@
|
||||
// Drop the in-memory draft and refetch *without* the
|
||||
// draft overlay — we don't trust the eventual delete
|
||||
// to have landed, so we read deployed directly.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
scriptHandle.draft = undefined
|
||||
await loadScript({ getDraft: false })
|
||||
}
|
||||
})
|
||||
@@ -242,76 +190,20 @@
|
||||
// deployed at the field level — the draft contributes editor
|
||||
// state (content, summary, …) and the deployed contributes
|
||||
// metadata the draft never carries (hash, version markers).
|
||||
// Same effective shape the backend used to deep-merge for us;
|
||||
// kept frontend-side now so the draft's editor shape can
|
||||
// diverge from the wire shape without confusing the server.
|
||||
const { draft: draftFromBackend, ...deployedScript } = backendScript as any
|
||||
const effectiveScript: EditableScript = draftFromBackend
|
||||
? { ...deployedScript, ...draftFromBackend }
|
||||
: (deployedScript as EditableScript)
|
||||
savedScript = structuredClone($state.snapshot(effectiveScript))
|
||||
|
||||
const localDraft = scriptHandle.draft
|
||||
const previousMeta = scriptHandle.meta
|
||||
const newRevs: UserDraftMeta = {
|
||||
remoteRev: backendScript.hash
|
||||
}
|
||||
|
||||
// Compute the fully-baked initial value once so the assignment
|
||||
// below is a single write — otherwise post-load mutations like
|
||||
// `parent_hash = ...` would count as a second write under
|
||||
// useLocalStorageValue's saveInitialValue=false contract and get
|
||||
// persisted before the user has touched anything.
|
||||
const bakedBaseline: EditableScript = {
|
||||
// Backend is canonical: write the baked baseline into the
|
||||
// cell. `parent_hash` is grafted on so the editor's compile
|
||||
// reuses the deployed lock. The first cell write after
|
||||
// `acquireEntry` is swallowed by the syncer's seed guard, so
|
||||
// this load doesn't POST.
|
||||
scriptHandle.draft = {
|
||||
...effectiveScript,
|
||||
parent_hash: topHash ?? backendScript.hash
|
||||
}
|
||||
|
||||
if (localDraft != undefined) {
|
||||
const referenceClean = cleanValueProperties(effectiveScript)
|
||||
const localClean = cleanValueProperties(localDraft)
|
||||
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
|
||||
// Local matches the saved version — silently drop it and use the saved one.
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
} else {
|
||||
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
|
||||
if (cause) {
|
||||
// Remote moved on since the local autosave was written —
|
||||
// surface the choice via modal. The local draft stays on
|
||||
// screen until the user picks.
|
||||
pendingBaseline = { baseline: bakedBaseline, revs: newRevs }
|
||||
staleModalCause = cause
|
||||
staleModalOpen = true
|
||||
} else {
|
||||
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
|
||||
// Legacy entry (no meta recorded) — backfill so future
|
||||
// loads can detect staleness even if the user doesn't edit.
|
||||
scriptHandle.setMeta(newRevs, { force: true })
|
||||
}
|
||||
const scriptPath = bakedBaseline.path
|
||||
notifyRestoredFromLocal(false, true, {
|
||||
onResetToSavedDraft: () => {
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
applyBaseline(bakedBaseline)
|
||||
},
|
||||
onResetToDeployed: async () => {
|
||||
UserDraft.remove('script', draftPath)
|
||||
// UserDraft.remove only clears localStorage. The entry's
|
||||
// in-memory state is kept alive by this route's handle, so
|
||||
// loadScript would re-read the stale autosave and the toast
|
||||
// would fire again. Drop the in-memory state first.
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
goto(`/scripts/edit/${scriptPath}`)
|
||||
loadScript()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
|
||||
}
|
||||
}
|
||||
|
||||
if (scriptHandle.draft) {
|
||||
@@ -351,19 +243,13 @@
|
||||
}
|
||||
diffDrawer?.closeDrawer()
|
||||
UserDraft.remove('script', draftPath)
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
scriptHandle.draft = undefined
|
||||
goto(`/scripts/edit/${savedScript.path}`)
|
||||
loadScript()
|
||||
}
|
||||
</script>
|
||||
|
||||
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} />
|
||||
<LocalDraftStaleModal
|
||||
open={staleModalOpen}
|
||||
cause={staleModalCause}
|
||||
onLoadLatest={onStaleLoadLatest}
|
||||
onKeepDraft={onStaleKeepDraft}
|
||||
/>
|
||||
{#if !hash && $workspaceStore && page.params.path}
|
||||
<DraftSyncConflictModal
|
||||
query={{ workspace: $workspaceStore, itemKind: 'script', path: page.params.path }}
|
||||
@@ -396,7 +282,7 @@
|
||||
{savedPrimarySchedule}
|
||||
searchParams={page.url.searchParams}
|
||||
onResetToDeployed={async () => {
|
||||
scriptHandle.setDraftAndMeta(undefined, {})
|
||||
scriptHandle.draft = undefined
|
||||
await loadScript({ getDraft: false })
|
||||
}}
|
||||
onDeploy={(e) => {
|
||||
|
||||
Reference in New Issue
Block a user