feat(frontend): DB-backed user-scoped drafts; drop local-storage + conflict UX

Rework UserDraft into a purely in-memory two-way sync layer and push every
change to the backend draft table via a new UserDraftDbService.

- userDraft.svelte.ts: no more localStorage, staleness/meta tracking, GC, or
  legacy migration. Reactive in-memory cells; the handle setter syncs each edit
  through UserDraftDbService (debounced). Callers seed the loaded value with
  setInitial() (no write-back); meta/setDraftAndMeta/setMeta kept as inert
  shims so non-editor callers compile.
- userDraftDbService.ts (new): save({path, itemKind, content}) maps the kind to
  a draft typ (script/flow/app; raw_app→app) and calls createDraft, or
  deleteDraft when content is undefined. Debounced; skips kinds without a DB
  draft and brand-new items (empty path).
- Remove all draft conflict UX: LocalDraftStaleModal, userDraftToast, the
  legacy-migration module, the staleness checks/modals/restore toasts and the
  URL-hash sync in the script/flow/app/raw-app editors.
- Drop draft_only everywhere it was read/sent (builders, rows, DraftBadge,
  editor headers, list filters include_draft_only, copilot, sessions).
- Editors now load their own value and seed it; edits autosync. "Restore to
  deployed" deletes the DB draft explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-03 16:44:03 +02:00
parent ae42cbf4a8
commit d4ecf4b9fd
31 changed files with 404 additions and 3579 deletions
+7 -17
View File
@@ -4,26 +4,16 @@
interface Props {
has_draft?: boolean
draft_only?: boolean
}
let { has_draft = false, draft_only = false }: Props = $props()
let { has_draft = false }: Props = $props()
</script>
{#if has_draft}
{#if draft_only}
<Popover notClickable>
{#snippet text()}
Never deployed and is only a draft
{/snippet}
<Badge small color="indigo">Draft only</Badge>
</Popover>
{:else}
<Popover notClickable>
{#snippet text()}
Is deployed and has a draft
{/snippet}
<Badge small color="indigo">+Draft</Badge>
</Popover>
{/if}
<Popover notClickable>
{#snippet text()}
Is deployed and has a draft
{/snippet}
<Badge small color="indigo">+Draft</Badge>
</Popover>
{/if}
+12 -29
View File
@@ -131,7 +131,6 @@
onDeployError,
onDetails,
onSaveDraftError,
onSaveDraftOnlyAtNewPath,
onHistoryRestore,
onNavigate
}: FlowBuilderProps = $props()
@@ -299,14 +298,7 @@
loadingDraft = true
try {
const flow = cleanFlow(flowStore.val)
if (newFlow || savedFlow?.draft_only) {
if (savedFlow?.draft_only) {
await FlowService.deleteFlowByPath({
workspace: $workspaceStore!,
path: initialPath,
keepCaptures: true
})
}
if (newFlow) {
if (!initialPath || $pathStore != initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
@@ -326,7 +318,6 @@
value: flow.value,
schema: flow.schema,
tag: flow.tag,
draft_only: true,
ws_error_handler_muted: flow.ws_error_handler_muted,
visible_to_runner_only: flow.visible_to_runner_only,
on_behalf_of_email: flow.on_behalf_of_email,
@@ -337,7 +328,7 @@
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath,
path: newFlow ? $pathStore : initialPath,
typ: 'flow',
value: {
...flow,
@@ -348,11 +339,10 @@
})
savedFlow = {
...(newFlow || savedFlow?.draft_only
...(newFlow
? {
...structuredClone($state.snapshot(flowStore.val)),
path: $pathStore,
draft_only: true
path: $pathStore
}
: savedFlow),
draft: {
@@ -365,11 +355,6 @@
let savedAtNewPath = false
if (newFlow) {
onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' })
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
savedAtNewPath = true
initialPath = $pathStore
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' })
// this is so we can use the flow builder outside of sveltekit
}
onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow })
sendUserToast('Saved as draft')
@@ -400,7 +385,7 @@
async function handleSaveFlowInternal(deploymentMsg?: string) {
await compareVersions()
if (onLatest || initialPath == '' || savedFlow?.draft_only) {
if (onLatest || initialPath == '') {
// Handle directly
await saveFlow(deploymentMsg)
} else {
@@ -800,15 +785,13 @@
}> = []
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) {
dropdownItems.push({
label: 'Exit & see details',
// Use the deployed path, not the live `$pathStore` — the latter
// reflects local rename edits that haven't been deployed yet,
// which would land the user on a 404 details page.
onClick: () => onDetails?.({ path: initialPath })
})
}
dropdownItems.push({
label: 'Exit & see details',
// Use the deployed path, not the live `$pathStore` — the latter
// reflects local rename edits that haven't been deployed yet,
// which would land the user on a 404 details page.
onClick: () => onDetails?.({ path: initialPath })
})
if (!untrack(() => newFlow)) {
dropdownItems.push({
@@ -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
@@ -80,42 +79,6 @@
// 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) => ({
itemKind: 'resource' as const,
@@ -262,34 +225,6 @@
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
}
// 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(s)
existedInitially[ws] = true
@@ -438,13 +373,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}
@@ -696,15 +696,7 @@
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
let newHash = ''
if (initialPath == '' || savedScript?.draft_only) {
if (savedScript?.draft_only) {
await ScriptService.deleteScriptByPath({
workspace: $workspaceStore!,
path: initialPath,
keepCaptures: true
})
script.parent_hash = undefined
}
if (initialPath == '') {
if (!initialPath || script.path != initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
@@ -727,7 +719,6 @@
language: script.language,
kind: script.kind,
tag: script.tag,
draft_only: true,
envs: script.envs,
concurrent_limit: script.concurrent_limit,
concurrency_time_window_s: script.concurrency_time_window_s,
@@ -762,7 +753,7 @@
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
path: initialPath == '' ? script.path : initialPath,
typ: 'script',
value: {
...script,
@@ -773,9 +764,7 @@
const clonedScript = structuredClone($state.snapshot(script))
savedScript = {
...(initialPath == '' || savedScript?.draft_only
? { ...clonedScript, draft_only: true }
: savedScript),
...(initialPath == '' ? clonedScript : savedScript),
draft: {
...clonedScript,
draft_triggers: draftTriggers
@@ -783,7 +772,7 @@
} as NewScriptWithDraftAndDraftTriggers
let savedAtNewPath = false
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
if (initialPath == '') {
savedAtNewPath = true
initialPath = script.path
onSaveInitial?.({ path: script.path, hash: newHash })
@@ -862,10 +851,7 @@
: [])
]
: []),
...(!inSessionPane &&
!script.draft_only &&
script.kind === 'script' &&
!script.auto_kind
...(!inSessionPane && script.kind === 'script' && !script.auto_kind
? [
{
label: 'Exit & See details',
@@ -1960,7 +1946,7 @@
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(script.language)}
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
isDeployed={savedScript && !savedScript?.draft_only}
isDeployed={!!savedScript}
schema={script.schema}
runnableVersion={script.parent_hash}
onDeployTrigger={handleDeployTrigger}
@@ -2122,7 +2108,7 @@
{template}
tag={script.tag}
lastSavedCode={savedScript?.draft?.content}
lastDeployedCode={savedScript?.draft_only ? undefined : savedScript?.content}
lastDeployedCode={savedScript?.content}
bind:args
bind:hasPreprocessor
bind:captureTable
@@ -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()
@@ -48,37 +47,6 @@
// 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) => ({
itemKind: 'variable' as const,
@@ -173,23 +141,6 @@
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
}
// 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(s)
existedInitially[ws] = true
@@ -315,13 +266,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'}
@@ -92,7 +92,6 @@
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
@@ -377,7 +376,6 @@
path: newEditedPath,
summary: $summary,
policy,
draft_only: true,
custom_path: customPath
}
})
@@ -400,7 +398,6 @@
value: structuredClone($state.snapshot($app)),
path: newEditedPath,
policy,
draft_only: true,
draft: {
summary: $summary,
value: structuredClone($state.snapshot($app)),
@@ -454,27 +451,10 @@
try {
policy = await updatePolicy($app, policy)
let path = $appPath
if (savedApp.draft_only) {
await AppService.deleteApp({
workspace: $workspaceStore!,
path: path
})
await AppService.createApp({
workspace: $workspaceStore!,
requestBody: {
value: $app!,
summary: $summary,
policy,
path: newEditedPath || path,
draft_only: true,
custom_path: customPath
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: savedApp.draft_only ? newEditedPath || path : path,
path: path,
typ: 'app',
value: {
value: $app!,
@@ -486,16 +466,7 @@
})
savedApp = {
...(savedApp?.draft_only
? {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: savedApp.draft_only ? newEditedPath || path : path,
policy,
draft_only: true,
custom_path: customPath
}
: savedApp),
...savedApp,
draft: {
summary: $summary,
value: structuredClone($state.snapshot($app)),
@@ -508,7 +479,7 @@
sendUserToast('Draft saved')
if (!inSessionPane) UserDraft.remove('app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
if (newApp) {
onSavedNewAppPath?.(newEditedPath || path)
}
} catch (e) {
@@ -689,7 +660,7 @@
action: () => {
appReportingDrawerOpen = true
},
disabled: !savedApp || savedApp.draft_only
disabled: !savedApp
},
{
displayName: 'Diff',
@@ -905,7 +876,7 @@
<div class="flex flex-row gap-4">
<Button
variant="accent"
disabled={!savedApp || savedApp.draft_only}
disabled={!savedApp}
on:click={async () => {
if (!savedApp) {
return
@@ -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}
@@ -14,7 +14,6 @@
import {
ExternalLink,
Eye,
File,
FileJson,
FolderOpen,
GitFork,
@@ -37,7 +36,7 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
app: ListableApp & { has_draft?: boolean; canWrite: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -86,7 +85,7 @@
path={app.path}
summary={app.summary}
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
canFavorite={!app.draft_only}
canFavorite={true}
{depth}
{keyboardSelected}
>
@@ -98,7 +97,7 @@
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
<DraftBadge has_draft={app.has_draft} draft_only={app.draft_only} />
<DraftBadge has_draft={app.has_draft} />
{#if app.labels?.length}
<div class="flex items-center gap-0.5">
{#each app.labels.slice(0, 3) as label}
@@ -155,40 +154,9 @@
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
items={async () => {
let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app
let { canWrite, summary, execution_mode, path, has_draft } = app
const canEdit = canWrite && showEditButton
if (draft_only) {
return [
{
displayName: 'Delete',
icon: Trash,
action: async (event) => {
// TODO
// @ts-ignore
if (event?.shiftKey) {
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
dispatch('change')
} else {
deleteConfirmedCallback = async () => {
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
dispatch('change')
}
}
},
type: 'delete',
disabled: !canEdit,
hide: $userStore?.operator
},
{
displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON',
icon: File,
action: () => {
loadAppJson()
}
}
]
}
return [
{
displayName: 'Duplicate/Fork',
@@ -39,7 +39,7 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
flow: Flow & { has_draft?: boolean; canWrite: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -103,16 +103,14 @@
<Row
aiId={`flow-row-${flow.path}`}
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
href={flow.draft_only
? `${base}/flows/edit/${flow.path}?nodraft=true`
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
href={`${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
kind="flow"
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
{marked}
path={flow.path}
summary={flow.summary}
{errorHandlerMuted}
canFavorite={!flow.draft_only}
canFavorite={true}
{depth}
{keyboardSelected}
>
@@ -121,7 +119,7 @@
<Badge color="red" baseClass="border">archived</Badge>
{/if}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
<DraftBadge has_draft={flow.has_draft} draft_only={flow.draft_only} />
<DraftBadge has_draft={flow.has_draft} />
{#if flow.labels?.length}
<div class="flex items-center gap-0.5">
{#each flow.labels.slice(0, 3) as label}
@@ -180,30 +178,9 @@
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
items={async () => {
let { draft_only, path, archived, has_draft } = flow
let { path, archived, has_draft } = flow
let owner = isOwner(path, $userStore, $workspaceStore)
const canEdit = flow.canWrite && showEditButton
if (draft_only) {
return [
{
displayName: 'Delete',
icon: Trash,
action: (event) => {
// @ts-ignore
if (event?.shiftKey) {
deleteFlow(path)
} else {
deleteConfirmedCallback = () => {
deleteFlow(path)
}
}
},
type: 'delete',
disabled: !owner,
hide: $userStore?.operator
}
]
}
return [
{
displayName: 'View runs',
@@ -121,7 +121,7 @@
<Row
aiId={`script-run-button-${script.path}`}
aiDescription={`Button to access the form to run the script ${script.summary ?? script.path}`}
href={script.draft_only || (script.auto_kind === 'lib' && script.kind !== 'preprocessor')
href={script.auto_kind === 'lib' && script.kind !== 'preprocessor'
? `${base}/scripts/edit/${script.path}`
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
kind="script"
@@ -130,7 +130,7 @@
summary={script.summary}
{errorHandlerMuted}
workspaceId={$workspaceStore ?? ''}
canFavorite={!script.draft_only}
canFavorite={true}
{depth}
{keyboardSelected}
>
@@ -168,7 +168,7 @@
>
{/if}
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
<DraftBadge has_draft={script.has_draft} draft_only={script.draft_only} />
<DraftBadge has_draft={script.has_draft} />
{#if script.labels?.length}
<div class="flex items-center gap-0.5">
{#each script.labels.slice(0, 3) as label}
@@ -239,34 +239,6 @@
items={async () => {
let owner = isOwner(script.path, $userStore, $workspaceStore)
const canEdit = script.canWrite && showEditButton
if (script.draft_only) {
return [
{
displayName: 'View code',
icon: Code,
action: () => {
showCode(script.path, script.summary)
}
},
{
displayName: 'Delete',
icon: Trash,
action: (event) => {
// TODO
// @ts-ignore
if (event?.shiftKey) {
deleteScript(script.path)
} else {
deleteConfirmedCallback = () => {
deleteScript(script.path)
}
}
},
type: dlt,
disabled: !canEdit
}
]
}
return [
{
displayName: 'View code',
@@ -424,10 +424,7 @@ export class AIChatManager {
return {
kind: 'flow',
path: this.flowOptions?.path,
deployed:
!!this.flowOptions?.path &&
!!this.flowOptions.lastDeployedFlow &&
!this.flowOptions.lastDeployedFlow.draft_only
deployed: !!this.flowOptions?.path && !!this.flowOptions.lastDeployedFlow
}
}
@@ -519,8 +516,7 @@ export class AIChatManager {
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
this.helpers = {
...(this.isSessionChat ? { sessionId: this.sessionId } : {}),
testActiveFlow: async (args?: Record<string, any>) =>
this.flowAiChatHelpers?.testFlow(args)
testActiveFlow: async (args?: Record<string, any>) => this.flowAiChatHelpers?.testFlow(args)
} satisfies GlobalToolHelpers
} else if (mode === AIMode.APP) {
const customPrompt = getCombinedCustomPrompt(mode)
@@ -128,10 +128,7 @@ const INSTRUCTION_SUBJECTS = [
// `datatable` is not a workspace item type, but the model can request the
// datatable SDK reference (the wmill.datatable() runnable API) the same way.
const INSTRUCTION_SUBJECTS_EXTRA = ['datatable'] as const
const ALL_INSTRUCTION_SUBJECTS = [
...INSTRUCTION_SUBJECTS,
...INSTRUCTION_SUBJECTS_EXTRA
] as const
const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const
const MAX_LIST_LIMIT = 100
type ActiveGlobalEditorType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'>
type LiveEditorDraftKind = Parameters<typeof UserDraft.getLiveEditorDraft>[0]
@@ -163,7 +160,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum)
const getInstructionsSchema = z.object({
subject: instructionSubjectSchema.describe(
"What to get authoring instructions for: a workspace item type (script, flow, resource, app) or \"datatable\" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don't need instructions — their tool schemas describe everything."
'What to get authoring instructions for: a workspace item type (script, flow, resource, app) or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.'
),
language: scriptLangSchema
.optional()
@@ -1208,7 +1205,6 @@ async function listWorkspaceItems(
workspace,
pathStart: pathPrefix,
perPage,
includeDraftOnly: true,
withoutDescription: true
})
for (const script of scripts) items.push(scriptToItem(script, false))
@@ -1219,7 +1215,6 @@ async function listWorkspaceItems(
workspace,
pathStart: pathPrefix,
perPage,
includeDraftOnly: true,
withoutDescription: true
})
for (const flow of flows) items.push(flowToItem(flow, false))
@@ -2208,13 +2203,7 @@ async function writeScriptDraft(
content: args.content,
language: args.language
}
UserDraft.setDraftAndMeta(
'script',
storagePath,
draft,
{ remoteRev: existing.hash, remoteDraftRev: existing.draft_created_at },
{ workspace }
)
UserDraft.save('script', storagePath, draft, { workspace })
} else {
const draft: NewScript = {
path: args.path,
@@ -2265,10 +2254,7 @@ async function writeFlowDraft(
}
UserDraft.save('flow', storagePath, draft, { workspace })
} else if (backendExists) {
const [existing, latestVersion] = await Promise.all([
FlowService.getFlowByPathWithDraft({ workspace, path: args.path }),
FlowService.getFlowLatestVersion({ workspace, path: args.path })
])
const existing = await FlowService.getFlowByPathWithDraft({ workspace, path: args.path })
const base = (existing.draft ?? existing) as Flow
const draft: Flow = {
...structuredClone(base),
@@ -2277,13 +2263,7 @@ async function writeFlowDraft(
value,
schema: draftValue.schema ?? base.schema
}
UserDraft.setDraftAndMeta(
'flow',
storagePath,
draft,
{ remoteRev: latestVersion.id, remoteDraftRev: existing.draft_created_at },
{ workspace }
)
UserDraft.save('flow', storagePath, draft, { workspace })
} else {
const draft: Flow = {
path: args.path,
@@ -2381,11 +2361,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 {
@@ -2418,11 +2397,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 {
@@ -354,11 +354,9 @@ export function saveGlobalAppDraft(
): 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 })
}
// Persist (and update any live editor) regardless of meta — meta tracking
// (staleness) was removed.
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
@@ -171,7 +171,7 @@
hasPreprocessor={!!flowStore.val.value.preprocessor_module}
canHavePreprocessor={true}
args={previewArgs.val}
isDeployed={savedFlow && !savedFlow?.draft_only}
isDeployed={!!savedFlow}
schema={flowStore.val.schema}
{onDeployTrigger}
/>
@@ -90,7 +90,6 @@
workspace: $workspaceStore!,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
includeDraftOnly: true,
withoutDescription: true
})
@@ -108,7 +107,6 @@
await FlowService.listFlows({
workspace: $workspaceStore!,
showArchived: archived ? true : undefined,
includeDraftOnly: true,
withoutDescription: true
})
).map((x: Flow) => {
@@ -124,17 +122,15 @@
}
async function loadApps(): Promise<void> {
apps = (await AppService.listApps({ workspace: $workspaceStore!, includeDraftOnly: true })).map(
(app: ListableApp) => {
return {
canWrite:
canWrite(app.path!, app.extra_perms!, $userStore) &&
app.workspace_id == $workspaceStore &&
!$userStore?.operator,
...app
}
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((app: ListableApp) => {
return {
canWrite:
canWrite(app.path!, app.extra_perms!, $userStore) &&
app.workspace_id == $workspaceStore &&
!$userStore?.operator,
...app
}
)
})
loading = false
}
@@ -107,7 +107,6 @@
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
@@ -474,7 +473,6 @@
path: newEditedPath,
summary: summary,
policy,
draft_only: true,
custom_path: customPath
},
js,
@@ -500,7 +498,6 @@
value: structuredClone(stateSnapshot(app)),
path: newEditedPath,
policy,
draft_only: true,
draft: {
summary: summary,
value: structuredClone(stateSnapshot(app)),
@@ -558,33 +555,10 @@
try {
await computeTriggerables()
let path = appPath
if (savedApp.draft_only) {
await AppService.deleteApp({
workspace: $workspaceStore!,
path: path
})
let { css, js } = await getBundle()
await AppService.createAppRaw({
workspace: $workspaceStore!,
formData: {
app: {
value: app!,
summary: summary,
policy,
path: newEditedPath || path,
draft_only: true,
custom_path: customPath
},
js,
css
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: savedApp.draft_only ? newEditedPath || path : path,
path: path,
typ: 'app',
value: {
value: app!,
@@ -596,16 +570,7 @@
})
savedApp = {
...(savedApp?.draft_only
? {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: savedApp.draft_only ? newEditedPath || path : path,
policy,
draft_only: true,
custom_path: customPath
}
: savedApp),
...savedApp,
draft: {
summary: summary,
value: structuredClone(stateSnapshot(app)),
@@ -618,7 +583,7 @@
sendUserToast('Draft saved')
if (!inSessionPane) UserDraft.remove('raw_app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
if (newApp) {
dispatch('savedNewAppPath', newEditedPath || path)
}
} catch (e) {
@@ -788,7 +753,7 @@
<div class="flex flex-row gap-2">
<Button
variant="default"
disabled={!savedApp || savedApp.draft_only}
disabled={!savedApp}
on:click={async () => {
if (!savedApp) {
return
@@ -424,7 +424,7 @@ function createRuntime(session: Session): SessionRuntime {
value: result.value as any,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft_only: false,
draft: result.draft,
custom_path: result.custom_path
}
@@ -456,7 +456,7 @@ function createRuntime(session: Session): SessionRuntime {
value: result.value as any,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft_only: false,
draft: result.draft,
custom_path: result.custom_path
}
@@ -9,10 +9,10 @@
import Select from '../select/Select.svelte'
interface Props {
value?: string;
value?: string
}
let { value = $bindable('') }: Props = $props();
let { value = $bindable('') }: Props = $props()
let darkMode = $state(false)
const { appPath } = getContext<AppViewerContext>('AppViewerContext')
@@ -20,17 +20,15 @@
let apps: ListableApp[] = $state([])
async function loadApps(): Promise<void> {
apps = (await AppService.listApps({ workspace: $workspaceStore!, includeDraftOnly: true })).map(
(app: ListableApp) => {
return {
canWrite:
canWrite(app.path!, app.extra_perms!, $userStore) &&
app.workspace_id == $workspaceStore &&
!$userStore?.operator,
...app
}
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((app: ListableApp) => {
return {
canWrite:
canWrite(app.path!, app.extra_perms!, $userStore) &&
app.workspace_id == $workspaceStore &&
!$userStore?.operator,
...app
}
)
})
}
onMount(() => {
@@ -102,7 +102,6 @@ export async function loadKind(
if (kind === 'flow') {
const flows = await FlowService.listFlows({
workspace,
includeDraftOnly: true,
withoutDescription: true
})
items = flows.map((f: Flow) => ({
@@ -113,7 +112,6 @@ export async function loadKind(
} else if (kind === 'script') {
const scripts = await ScriptService.listScripts({
workspace,
includeDraftOnly: true,
withoutDescription: true
})
items = scripts.map((s: Script) => ({
@@ -123,8 +121,7 @@ export async function loadKind(
}))
} else {
const apps = await AppService.listApps({
workspace,
includeDraftOnly: true
workspace
})
items = apps.map((a: ListableApp) => ({
path: a.path,
+135 -509
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store'
import { onDestroy, untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import { workspaceStore } from './stores'
import { useLocalStorageValue } from './svelte5Utils.svelte'
import { UserDraftDbService } from './userDraftDbService'
export const USER_DRAFT_ITEM_KINDS = [
'script',
@@ -39,9 +39,9 @@ export type UserDraftOptions = {
export type UserDraftUseOptions<V> = UserDraftOptions & {
/**
* Initial value used when localStorage holds no draft for this
* (workspace, itemKind, path). It is *not* eagerly persisted the first
* actual mutation is what writes to localStorage.
* Initial in-memory value for this (workspace, itemKind, path). It is *not*
* synced to the DB only subsequent edits are. Callers are responsible for
* loading the real value (e.g. from `getXByPathWithDraft`) and seeding it.
*/
defaultValue?: V
}
@@ -50,11 +50,6 @@ 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> = {
itemKind: UserDraftItemKind
path: string
@@ -63,68 +58,23 @@ export type UserDraftSpec<V> = {
}
/**
* Snapshot of the remote item's freshness at the moment the local draft was
* written. Used by editor routes to detect that the remote has moved on
* (someone else deployed, or saved a DB draft) so we can warn the user
* before they push stale changes.
* Vestigial type kept so callers that still pass/read a "meta" object compile.
* The old staleness/conflict-resolution layer is gone; meta is ignored
* (writes are dropped, reads return `{}`).
*
* - `remoteRev`: the deployed version's id/hash/timestamp at draft creation.
* - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set
* for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`).
* @deprecated drafts no longer track remote revs / staleness.
*/
export type UserDraftMeta = {
remoteRev?: string | number
remoteDraftRev?: string | number
}
/**
* The shape of what we actually persist. Wrapping the value lets us add
* metadata (timestamps, originating user, schema version, ...) later
* without breaking existing entries.
*
* `lastWrittenAt` is the unix-ms timestamp of the most recent write
* (setter call or deep mutation flush). It's the GC signal
* `gcUserDrafts` sweeps entries that haven't been touched in N days.
* Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`,
* `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing
* (undefined) on entries written before this field was introduced;
* `gcUserDrafts` backfills them on first sighting.
*/
type StoredDraft<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
if (stored === undefined) return undefined
return { ...stored, lastWrittenAt: Date.now() }
}
type DraftState<V> = {
val: StoredDraft<V> | undefined
skipNextWriteOnce(): void
setWithoutPersist(newVal: StoredDraft<V> | undefined): void
}
type DraftEntry = {
count: number
workspace: string
itemKind: UserDraftItemKind
path: string
state: DraftState<unknown>
/**
* Tears down the `$effect.root` scope that owns the entry's
* `useLocalStorageValue` reactivity its `$state` cell and the persist
* `$effect` deep-mutation loop. Called when the refcount hits 0.
*
* `undefined` only when the test runtime's broken `$effect.root` forced
* us through the fallback path (see `acquireEntry`).
*/
destroyRoot?: () => void
}
export type UserDraftEntry<V = unknown> = {
workspace: string
itemKind: UserDraftItemKind
path: string
value: V | undefined
/** Always `{}` — kept for shape compatibility. */
meta: UserDraftMeta
persisted: boolean
live: boolean
@@ -148,6 +98,22 @@ export type ClearLiveEditorDraftOptions = UserDraftOptions & {
storagePath?: string
}
/**
* A single in-memory draft cell. Reactive via `$state` so editors can bind to
* it; the refcount keeps it alive while at least one `use*` spec references it.
*/
class DraftCell {
value = $state<unknown>(undefined)
}
type DraftEntry = {
count: number
workspace: string
itemKind: UserDraftItemKind
path: string
cell: DraftCell
}
const entries = new Map<string, DraftEntry>()
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
@@ -161,115 +127,14 @@ 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.
*
* - Entries with no recorded meta (legacy entries written before this field
* existed) report `null` we can't tell if they're stale, and we'd rather
* trust the local autosave than spam the user with false positives.
* - DB-draft staleness wins over deployed-version staleness: a remote DB
* draft is the more recent state to reconcile against.
* - If a DB draft existed when the local autosave was created but now no
* longer exists on the remote (someone discarded it), we report `version`
* because the deployed version is now the canonical "latest saved".
*/
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
}
/**
* Synchronous localStorage write, bypassing the entry's debounced setter
* and its first-write skip. See `setMeta({ force: true })`.
*/
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
try {
const next = stamp(wrap(value, meta))
if (next === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(next))
}
} catch (e) {
console.error('UserDraft: localStorage write failed', e)
}
}
function readPersisted<V>(key: string): StoredDraft<V> | undefined {
try {
const raw = localStorage.getItem(key)
if (raw == null || raw === 'undefined') return undefined
const parsed = JSON.parse(raw)
// Defensive: ignore pre-wrapping payloads (no `.value`).
if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined
return parsed as StoredDraft<V>
} catch (e) {
console.error('UserDraft: localStorage read failed', e)
return undefined
}
}
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `${workspace}/${itemKind}/${path}`
}
function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `userdraft/w/${workspace}/${itemKind}/${path}`
}
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
return `${workspace}/${itemKind}`
}
function parseLocalStorageKey(
key: string,
workspace: string,
itemKinds: readonly UserDraftItemKind[]
): { itemKind: UserDraftItemKind; path: string } | undefined {
const prefix = `userdraft/w/${workspace}/`
if (!key.startsWith(prefix)) return undefined
const rest = key.slice(prefix.length)
for (const itemKind of itemKinds) {
const kindPrefix = `${itemKind}/`
if (rest.startsWith(kindPrefix)) {
return { itemKind, path: rest.slice(kindPrefix.length) }
}
}
return undefined
}
function snapshotDraftValue<V>(value: V | undefined): V | undefined {
if (value === undefined) return undefined
try {
@@ -285,35 +150,26 @@ function snapshotDraftValue<V>(value: V | undefined): V | undefined {
export type UserDraftHandle<V> = {
get draft(): V | undefined
/** Set the draft and sync the change to the DB (debounced). */
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.
* Seed the in-memory value WITHOUT syncing to the DB. Use when loading the
* value the editor already fetched from the backend, so re-binding it does
* not write it straight back.
*/
setInitial(value: V | undefined): void
/** @deprecated meta is ignored — alias of `draft = value`. */
get meta(): UserDraftMeta
/**
* Set value AND rev metadata in one write (no extra persist). Later
* `draft = X` writes preserve the rev metadata.
*/
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void
/**
* Update rev metadata without touching the value. `{ force: true }` also
* persists synchronously use when this may be the entry's first write,
* else the ack is lost on remount.
*/
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
/** @deprecated meta is ignored — sets the value and syncs it. */
setDraftAndMeta(value: V | undefined, meta?: UserDraftMeta): void
/** @deprecated no-op (staleness tracking removed). */
setMeta(meta?: UserDraftMeta, opts?: { force?: boolean }): void
}
/**
* JSON round-trip normalization. localStorage persistence stringifies the
* draft, which silently drops keys whose value is `undefined`, turns `Date`
* into a string, etc. A freshly-built config object (e.g. a trigger editor's
* `getXConfig()`) keeps those `undefined`-valued keys, so a raw
* `deepEqual(persistedDraft, freshConfig)` 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 (e.g. a cyclic structure) better a false "differs" than a
* throw inside a load/effect path.
* JSON round-trip normalization. Drops `undefined`-valued keys, turns `Date`
* into a string, etc., so two structurally-equal configs compare equal even if
* one was freshly built (keeps `undefined` keys) and the other round-tripped.
*/
export function normalizeForCompare<V>(value: V | undefined): V | undefined {
if (value === undefined) return undefined
@@ -325,20 +181,9 @@ export function normalizeForCompare<V>(value: V | undefined): V | undefined {
}
/**
* Whether the persisted local autosave (`localDraft`, as returned by
* `UserDraft.get`) meaningfully differs from the freshly-built
* `currentConfig`. Editor restore guards use this to decide whether to
* overlay the local autosave and toast.
*
* Returns `false` when there is no local draft. Normalizes both sides (see
* `normalizeForCompare`) so a draft that round-trips equal to the deployed
* config e.g. one written by merely opening then closing the editor with
* no edits is correctly treated as "no meaningful draft" instead of
* spuriously triggering a restore on every reopen.
*
* Typed as a guard: a `true` result narrows `localDraft` to non-nullish
* `V`, mirroring the `localCfg && …` narrowing it replaces so call sites
* can pass the draft straight into `loadXConfig(...)` without re-checking.
* Whether `localDraft` meaningfully differs from `currentConfig` (after
* `normalizeForCompare`). Typed as a guard so a `true` result narrows
* `localDraft` to non-nullish `V`.
*/
export function localDraftDiffers<V>(
localDraft: V | undefined | null,
@@ -348,61 +193,49 @@ export function localDraftDiffers<V>(
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
}
function setCell(
workspace: string,
itemKind: UserDraftItemKind,
path: string,
value: unknown | undefined,
sync: boolean
): void {
const mk = mapKey(workspace, itemKind, path)
const entry = entries.get(mk)
if (entry) {
entry.cell.value = value
}
if (sync) {
UserDraftDbService.save({ path, itemKind, content: snapshotDraftValue(value), workspace })
}
}
export const UserDraft = {
/** Set the draft value and sync it to the DB (debounced). */
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Static writes are external mutations. Update live observers and
// force the storage slot to match, even if the live entry still has
// its initial-write skip armed.
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
const meta = extractMeta(current)
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
// No live handle: preserve any persisted meta so the staleness
// signal survives a write while the editor is closed.
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
try {
localStorage.setItem(
localStorageKey(ws, itemKind, path),
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
)
} catch (e) {
console.error('UserDraft.save: localStorage write failed', e)
}
setCell(ws, itemKind, path, value, true)
},
/**
* @deprecated meta is ignored. Seeds the in-memory value WITHOUT syncing
* (it was historically used to install a known/loaded baseline). Use
* `save` to persist a programmatic change.
*/
setDraftAndMeta<V>(
itemKind: UserDraftItemKind,
path: string,
value: V | undefined,
meta: UserDraftMeta,
_meta?: UserDraftMeta,
opts?: UserDraftOptions
): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Static writes represent explicit external draft mutations. A
// freshly acquired live entry may still have the initial-write skip
// armed, so force the storage slot to match the live value.
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
setCell(ws, itemKind, path, value, false)
},
/**
* Autosave gate: persist `value` only when it differs (after
* `normalizeForCompare`) from the `deployed` baseline; otherwise remove
* any draft. Without this, opening and closing an editor with no edits
* would leave a no-op draft that `has()` / restore guards treat as
* unsaved work.
* Sync `value` only if it differs (after `normalizeForCompare`) from the
* `deployed` baseline; otherwise delete the draft.
*/
saveIfChanged<V>(
itemKind: UserDraftItemKind,
@@ -424,129 +257,75 @@ export const UserDraft = {
opts?: UserDraftOptions
): V | undefined {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
}
return snapshotDraftValue(unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path))))
const entry = entries.get(mapKey(ws, itemKind, path))
return snapshotDraftValue(untrack(() => entry?.cell.value as V | undefined))
},
/**
* Update the rev metadata for an entry without touching the value, and
* persist immediately. Used by editor routes that don't hold a live
* handle (apps, raw apps) they read the local draft via `UserDraft.get`
* and the handle is created later inside the child editor.
*
* No-op when the entry has no draft to attach meta to.
*/
/** @deprecated meta is ignored — always returns `{}`. */
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) {
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
if (current === undefined) return
entry.state.val = wrap(current.value, meta)
}
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
if (existing === undefined) return
persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta)
_itemKind: UserDraftItemKind,
_path: string,
_meta?: UserDraftMeta,
_opts?: UserDraftOptions
): void {},
/** @deprecated meta is ignored — always returns `{}`. */
getMeta(_itemKind: UserDraftItemKind, _path: string, _opts?: UserDraftOptions): UserDraftMeta {
return {}
},
/**
* Read the rev metadata for the entry. Returns an empty object if there
* is no entry. Useful for staleness checks before reading the draft.
*/
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 extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
return extractMeta(readPersisted<unknown>(localStorageKey(ws, itemKind, path)))
},
/**
* Whether a draft currently exists for (workspace, itemKind, path).
* Falls back to the persisted localStorage entry when no live handle is
* registered. Useful for distinguishing "first visit" from "returning
* visit with unsaved local changes".
*/
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return entry.state.val !== undefined
return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined
const entry = entries.get(mapKey(ws, itemKind, path))
return untrack(() => entry?.cell.value) !== undefined
},
/** Drop the draft: clear the in-memory value and delete the DB draft. */
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
} catch (e) {
console.error('UserDraft.remove: localStorage remove failed', e)
}
setCell(ws, itemKind, path, undefined, true)
},
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
UserDraft.discard(itemKind, path, undefined, opts)
},
/**
* Reset the in-memory value to `fallback` (e.g. the deployed baseline) and
* delete the DB draft. `fallback` is deep-cloned so the handle and the
* caller's baseline don't share a proxy.
*/
discard<V>(
itemKind: UserDraftItemKind,
path: string,
fallback: V | undefined,
opts?: UserDraftOptions
): void {
const ws = resolveWorkspace(opts)
const safeFallback = snapshotDraftValue(fallback)
setCell(ws, itemKind, path, safeFallback, false)
UserDraftDbService.save({ path, itemKind, content: undefined, workspace: ws })
},
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
const ws = resolveWorkspace(opts)
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
const out = new Map<string, UserDraftEntry<V>>()
if (typeof localStorage !== 'undefined') {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key)
}
for (const key of keys) {
const parsed = parseLocalStorageKey(key, ws, itemKinds)
if (!parsed) continue
const stored = readPersisted<V>(key)
if (stored === undefined) continue
out.set(mapKey(ws, parsed.itemKind, parsed.path), {
workspace: ws,
itemKind: parsed.itemKind,
path: parsed.path,
value: snapshotDraftValue(unwrap(stored)),
meta: extractMeta(stored),
persisted: true,
live: false
})
}
}
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)
const mk = mapKey(entry.workspace, entry.itemKind, entry.path)
if (stored === undefined) {
out.delete(mk)
continue
}
const existing = out.get(mk)
out.set(mk, {
const value = untrack(() => entry.cell.value as V | undefined)
if (value === undefined) continue
out.push({
workspace: entry.workspace,
itemKind: entry.itemKind,
path: entry.path,
value: snapshotDraftValue(unwrap(stored)),
meta: extractMeta(stored),
persisted: existing?.persisted ?? false,
value: snapshotDraftValue(value),
meta: {},
persisted: false,
live: true
})
}
return Array.from(out.values())
return out
},
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
@@ -577,72 +356,20 @@ export const UserDraft = {
liveEditorDrafts.delete(key)
},
/**
* Like `remove`, but also resets any live handle's `draft` to
* `fallback` in-memory (so reactive readers see it immediately) and
* skips re-persisting it, leaving the LS slot empty until the next real
* edit. Pass the deployed baseline as `fallback`.
*
* `fallback` is deep-cloned before being installed otherwise a caller
* who passes their own live `$state` baseline (e.g. resource/variable
* editors' `initialStates[ws]`) would end up with `handle.draft` and the
* baseline pointing at the *same* proxy; subsequent edits would mutate
* both sides in lock-step and the dirty check would never fire.
*/
discard<V>(
itemKind: UserDraftItemKind,
path: string,
fallback: V | undefined,
opts?: UserDraftOptions
): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
const safeFallback = snapshotDraftValue(fallback)
if (entry) {
// Drop any queued debounced write owned by this live entry before
// resetting the in-memory value. Otherwise a timer from the old
// entry can outlive unmount and later delete a freshly written
// draft for the same key.
entry.state.setWithoutPersist(wrap(safeFallback) as StoredDraft<unknown> | undefined)
}
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
} catch (e) {
console.error('UserDraft.discard: localStorage remove failed', e)
}
},
use<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftUseOptions<V>
): UserDraftHandle<V> {
// `use()` is a single-spec wrapper around `useMany`. We untrack the
// getter so that reactive opts (e.g. `$workspaceStore`) are captured
// once at call time — the current `use()` contract is "the handle
// stays bound to this workspace until the component unmounts." Use
// `useMany` directly if you want spec changes to release/acquire
// entries as you go.
const handles = UserDraft.useMany<V>(() =>
untrack(() => [
{
itemKind,
path,
workspace: opts?.workspace,
defaultValue: opts?.defaultValue
}
{ itemKind, path, workspace: opts?.workspace, defaultValue: opts?.defaultValue }
])
)
return handles[0]
},
useMany<V = unknown>(getSpecs: () => UserDraftSpec<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 reused across reconciles so
// callers can capture a reference and keep it alive — only the
// underlying entry's refcount moves.
const handles = $state<UserDraftHandle<V>[]>([])
const acquired = new Set<string>()
const handleCache = new Map<string, UserDraftHandle<V>>()
@@ -677,21 +404,12 @@ export const UserDraft = {
}
}
// Skip no-op mutations (handles are cached by mapKey, so an
// unchanged spec set yields reference-equal arrays). `untrack` so
// this effect doesn't subscribe to its own `handles` write —
// otherwise it self-loops (`effect_update_depth_exceeded`).
// Downstream notification still propagates.
untrack(() => {
const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i])
if (!unchanged) handles.splice(0, handles.length, ...next)
})
}
// Synchronous initial reconcile so single-spec callers (`use()`) get a
// populated `handles[0]` before the function returns. Reactive reads
// inside `getSpecs()` here are intentionally not tracked — the
// `$effect` below picks up any subsequent dependency changes.
untrack(reconcile)
$effect(reconcile)
onDestroy(() => {
@@ -716,40 +434,9 @@ function acquireEntry(
existing.count++
return
}
// `useLocalStorageValue`'s internal persist `$effect` would otherwise
// parent to `useMany`'s reconcile effect and be torn down on the next
// reconcile. `$effect.root` gives the entry its own scope, disposed only
// by `releaseEntry`.
const useLocalStorageOptions = {
// First value is the baseline (don't persist it); coalesce edits.
saveInitialValue: false,
debounce: 500,
// Stamp `lastWrittenAt` at persist time so deep mutations also bump
// the GC clock (the setter doesn't re-run for those).
transformBeforePersist: stamp<unknown>
} as const
let stateRef: DraftState<unknown> | undefined
const destroyRoot = $effect.root(() => {
stateRef = useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(workspace, itemKind, path),
wrap(defaultValue),
undefined,
useLocalStorageOptions
)
})
if (stateRef) {
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
return
}
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
// invoked. Unreachable in production (Svelte runs it synchronously).
const state = useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(workspace, itemKind, path),
wrap(defaultValue),
undefined,
useLocalStorageOptions
)
entries.set(mk, { count: 1, workspace, itemKind, path, state })
const cell = new DraftCell()
cell.value = defaultValue
entries.set(mk, { count: 1, workspace, itemKind, path, cell })
}
function releaseEntry(mk: string): void {
@@ -757,7 +444,6 @@ function releaseEntry(mk: string): void {
if (!entry) return
entry.count--
if (entry.count <= 0) {
entry.destroyRoot?.()
entries.delete(mk)
}
}
@@ -767,96 +453,36 @@ function makeHandle<V>(
itemKind: UserDraftItemKind,
path: string
): UserDraftHandle<V> {
// The handle reads `entries.get(mk)` on every access. The entry it points
// at is stable as long as the refcount stays > 0 (which `useMany` keeps
// the case for as long as a spec references it). If the refcount drops to
// 0 and the entry is destroyed, reads return `undefined` rather than
// throwing — the consumer should already have been torn down by that point.
const mk = mapKey(workspace, itemKind, path)
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
const cellOf = (): DraftCell | undefined => entries.get(mk)?.cell
return {
get draft(): V | undefined {
return unwrap(stateOf()?.val as StoredDraft<V> | undefined)
return cellOf()?.value 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))
const cell = cellOf()
if (!cell) return
const prev = untrack(() => cell.value)
cell.value = value
// Skip syncing no-op writes (e.g. a `bind:` writing the seeded value
// straight back). Only real edits hit the DB.
if (!deepEqual(normalizeForCompare(value), normalizeForCompare(prev as V | undefined))) {
UserDraftDbService.save({ path, itemKind, content: snapshotDraftValue(value), workspace })
}
},
setInitial(value: V | undefined): void {
const cell = cellOf()
if (!cell) return
cell.value = value
},
get meta(): UserDraftMeta {
return extractMeta(stateOf()?.val as StoredDraft<unknown> | undefined)
return {}
},
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void {
const state = stateOf()
if (!state) return
state.val = wrap(value, meta)
setDraftAndMeta(value: V | undefined, _meta?: UserDraftMeta): void {
// Seed without syncing (historically used to install a loaded baseline).
this.setInitial(value)
},
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void {
// Read under `untrack` for the same reason as `set draft` above —
// avoid making any surrounding effect re-fire on the write below.
const state = stateOf()
if (!state) return
const current = untrack(() => state.val as StoredDraft<V> | undefined)
if (current === undefined) return
state.val = wrap(current.value, meta)
if (opts?.force) {
persistDirect(localStorageKey(workspace, itemKind, path), current.value, meta)
}
}
}
}
/**
* Default GC retention window: 30 days. Entries that haven't been touched
* (no setter call, no deep-mutation persist) for this long are swept on
* the next `gcUserDrafts` invocation.
*/
export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
/**
* Sweep stale UserDraft entries from localStorage. Walks every
* `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes
* any entry older than `maxAgeMs`.
*
* Entries written before `lastWrittenAt` was introduced lack the field;
* we backfill them to `now()` on first sighting so they participate in
* the next sweep cycle rather than getting wiped immediately.
*
* Safe to call on every load and on a timer (e.g. every 30 min) live
* entries get their stamp refreshed on every persist, so the sweep only
* touches truly stale records.
*/
export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void {
if (typeof localStorage === 'undefined') return
const now = Date.now()
const cutoff = now - maxAgeMs
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k != null && k.startsWith('userdraft/w/')) keys.push(k)
}
for (const key of keys) {
try {
const raw = localStorage.getItem(key)
if (raw == null) continue
const parsed = JSON.parse(raw)
if (parsed == null || typeof parsed !== 'object') continue
if (typeof parsed.lastWrittenAt !== 'number') {
// Pre-GC-feature entry. Backfill so the next sweep can decide.
parsed.lastWrittenAt = now
localStorage.setItem(key, JSON.stringify(parsed))
continue
}
if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key)
} catch (e) {
console.error('UserDraft GC: failed to inspect', key, e)
}
setMeta(_meta?: UserDraftMeta, _opts?: { force?: boolean }): void {}
}
}
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
import { get } from 'svelte/store'
import { DraftService } from './gen'
import { workspaceStore } from './stores'
import type { UserDraftItemKind } from './userDraft.svelte'
/**
* Persists `UserDraft` changes to the backend `draft` table. `UserDraft` is a
* purely in-memory two-way sync layer; this service is the bridge that turns
* each in-memory change into a `DraftService.createDraft` / `deleteDraft` call.
*
* Only the kinds that have a backing DB draft are persisted (script/flow/app,
* and raw_app which is stored under the `app` draft type). Every other kind
* (resource, variable, the trigger_* kinds which are folded into their
* parent script/flow draft via `draft_triggers`) is in-memory only and is a
* no-op here.
*
* Writes are debounced per (workspace, typ, path) so a burst of edits collapses
* into a single network call; the latest payload always wins.
*/
type DraftTyp = 'script' | 'flow' | 'app'
const ITEM_KIND_TO_DRAFT_TYP: Partial<Record<UserDraftItemKind, DraftTyp>> = {
script: 'script',
flow: 'flow',
app: 'app',
raw_app: 'app'
}
export type UserDraftDbSaveArgs = {
path: string
itemKind: UserDraftItemKind
/** The draft content to persist. `undefined` deletes the DB draft. */
content: unknown | undefined
/** Defaults to the current `$workspaceStore`. */
workspace?: string
}
const DEBOUNCE_MS = 600
type Pending = {
timer: ReturnType<typeof setTimeout>
content: unknown | undefined
workspace: string
typ: DraftTyp
path: string
}
const pending = new Map<string, Pending>()
function key(workspace: string, typ: DraftTyp, path: string): string {
return `${workspace}/${typ}/${path}`
}
async function flush(k: string): Promise<void> {
const p = pending.get(k)
if (!p) return
pending.delete(k)
try {
if (p.content === undefined) {
await DraftService.deleteDraft({ workspace: p.workspace, kind: p.typ, path: p.path })
} else {
await DraftService.createDraft({
workspace: p.workspace,
requestBody: { path: p.path, typ: p.typ, value: p.content }
})
}
} catch (e) {
// Drafts are best-effort autosave — never interrupt the editor with a
// toast or modal (see the drafts UX simplification). Log and move on.
console.error('UserDraftDbService: failed to sync draft', p.typ, p.path, e)
}
}
export const UserDraftDbService = {
/**
* Schedule a debounced persist of `content` for (workspace, itemKind, path).
* `content === undefined` deletes the draft. No-op for kinds without a DB
* draft.
*/
save({ path, itemKind, content, workspace }: UserDraftDbSaveArgs): void {
const typ = ITEM_KIND_TO_DRAFT_TYP[itemKind]
if (!typ) return
// A brand-new item is edited under the empty storage path until it gets a
// real path; there is nothing valid to persist a draft against yet (the
// explicit "Save as draft" with a real path handles that).
if (path === '') return
const ws = workspace ?? get(workspaceStore)
if (!ws) return
const k = key(ws, typ, path)
const existing = pending.get(k)
if (existing) clearTimeout(existing.timer)
const timer = setTimeout(() => flush(k), DEBOUNCE_MS)
pending.set(k, { timer, content, workspace: ws, typ, path })
},
/** Whether `itemKind` is persisted to the DB (vs in-memory only). */
hasDbDraft(itemKind: UserDraftItemKind): boolean {
return ITEM_KIND_TO_DRAFT_TYP[itemKind] !== undefined
}
}
@@ -1,223 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
migrateLegacyUserDrafts,
__resetUserDraftLegacyMigrationForTesting
} from './userDraftLegacyMigration'
function encodeLegacy(value: unknown): string {
return btoa(encodeURIComponent(JSON.stringify(value)))
}
function wrapped<V>(value: V): string {
return JSON.stringify({ value })
}
// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions
// can match the `{ value }` shape regardless of when the migration ran.
function storedShape(key: string): string | null {
const raw = localStorage.getItem(key)
if (raw == null) return null
const parsed = JSON.parse(raw)
delete parsed.lastWrittenAt
return JSON.stringify(parsed)
}
beforeEach(() => {
localStorage.clear()
__resetUserDraftLegacyMigrationForTesting()
})
describe('migrateLegacyUserDrafts', () => {
it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => {
// Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`,
// i.e. the inner App value, not the wrapping AppWithLastVersion.
const legacyApp = {
grid: [],
fullscreen: false,
theme: undefined,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-u/me/dashboard')).toBeNull()
expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
})
it('migrates a legacy empty-path app draft (the `app` literal key)', () => {
const legacyApp = {
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
localStorage.setItem('app', encodeLegacy(legacyApp))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app')).toBeNull()
expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
})
it('migrates a legacy flow draft and strips the view-state envelope', () => {
const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' }
const legacyBundle = {
flow,
path: 'u/me/myflow',
selectedId: 'settings',
draft_triggers: [{ id: 't1' }],
selected_trigger: null,
loadedFromHistory: undefined
}
localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('flow-u/me/myflow')).toBeNull()
// Only the inner Flow survives; the view-state envelope is dropped.
expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
})
it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => {
const legacy = {
files: { 'index.tsx': 'export default () => null' },
runnables: {},
data: { tables: [] }
}
localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('rawapp-u/me/site')).toBeNull()
expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe(
wrapped({ ...legacy, summary: '' })
)
})
it('preserves an existing new-format entry instead of overwriting it', () => {
// Old and new both exist for the same item — the new one is presumed
// fresher.
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
const existingNew = wrapped({ value: 'new' })
localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-u/me/dash')).toBeNull()
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew)
})
it('is idempotent — the second invocation is a no-op', () => {
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull()
// Drop the migrated entry to detect any re-migration attempt.
localStorage.removeItem('userdraft/w/main/app/u/me/dash')
// Drop the source too, so re-running couldn't even find a source.
// (The sentinel alone should be enough; this just clarifies the intent.)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
})
it('skips entirely when no workspace is available', () => {
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
migrateLegacyUserDrafts('')
expect(localStorage.getItem('app-u/me/dash')).not.toBeNull()
})
it('handles malformed legacy payloads without throwing', () => {
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
expect(() => migrateLegacyUserDrafts('main')).not.toThrow()
// Migration didn't migrate, didn't crash — leaves the entry alone.
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
})
it('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => {
// A future feature or neighbouring code might pick a key like
// `app-recent` for its own purposes. The path doesn't look like a
// Windmill item path, so the migration must skip it.
localStorage.setItem('app-recent', 'whatever')
localStorage.setItem('app-some_other_app', 'whatever')
// `flow-u/me/foo` matches the shape and would be migrated, but the
// payload also needs to look like a Windmill draft (asserted below).
localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } }))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-recent')).toBe('whatever')
expect(localStorage.getItem('app-some_other_app')).toBe('whatever')
expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull()
})
it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => {
// `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON,
// but none of the App-shape fields (grid/fullscreen/theme/
// unusedInlineScripts/hiddenInlineScripts) are present. Treat it as
// unrelated and leave it untouched.
const unrelated = encodeLegacy({ random: 'data', count: 7 })
localStorage.setItem('app-u/me/dash', unrelated)
const unrelatedFlow = encodeLegacy({ stepsState: {} })
localStorage.setItem('flow-u/me/bar', unrelatedFlow)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated)
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow)
expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull()
})
it('migrates multiple legacy entries in a single invocation', () => {
localStorage.setItem(
'app-u/me/a',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
localStorage.setItem(
'flow-u/me/b',
encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } })
)
localStorage.setItem(
'rawapp-u/me/c',
encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } })
)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull()
expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull()
expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull()
})
})
@@ -1,192 +0,0 @@
/**
* One-off migration from the pre-UserDraft localStorage autosave entries to
* the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format.
*
* Legacy keys (global, not workspace-scoped assumed to belong to the user's
* current workspace at migration time):
*
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
* `app` / `app-{path}` base64 of `encodeState(App)`
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
*
* Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing
* `JSON.stringify({ value: <transformed legacy value> })`.
*
* Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so
* subsequent invocations are no-ops. Existing new-format entries are never
* overwritten when both an old and a new entry exist for the same item, the
* old one is simply dropped on the assumption that the new entry is the more
* recent edit.
*
* This file is intentionally standalone it does not import from
* `userDraft.svelte.ts` so the new code stays uncluttered by the legacy
* decoders.
*/
const MIGRATION_FLAG = 'userdraft/legacy_migrated_v1'
type LegacyKind = 'flow' | 'app' | 'raw_app'
const LEGACY_PREFIXES: ReadonlyArray<{ prefix: string; newKind: LegacyKind }> = [
// `rawapp` is listed before `app` even though our matcher uses exact /
// dash-separated comparison (so there's no ambiguity); it documents the
// intent that raw apps are a distinct kind, not a sub-case of apps.
{ prefix: 'rawapp', newKind: 'raw_app' },
{ prefix: 'flow', newKind: 'flow' },
{ prefix: 'app', newKind: 'app' }
]
/**
* A Windmill item path: `u/<owner>/<name…>` or `f/<folder>/<name…>`. The
* `<name…>` segment may itself contain slashes, so we don't constrain it
* past requiring at least one character. Used to reject incidentally-named
* localStorage keys (e.g. `app-recent` from a future feature, or a
* neighbouring app's data) before treating them as Windmill drafts.
*/
const LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/
function matchLegacyKey(
key: string
): { prefix: string; newKind: LegacyKind; path: string } | undefined {
for (const { prefix, newKind } of LEGACY_PREFIXES) {
if (key === prefix) return { prefix, newKind, path: '' }
if (key.startsWith(prefix + '-')) {
const path = key.slice(prefix.length + 1)
if (!LEGACY_PATH_SHAPE.test(path)) return undefined
return { prefix, newKind, path }
}
}
return undefined
}
function decodeLegacyState(raw: string): unknown {
try {
return JSON.parse(decodeURIComponent(atob(raw)))
} catch {
return undefined
}
}
/**
* Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are
* unusual enough that nothing else in the codebase has used them, but
* matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a
* Windmill draft (any base64-of-JSON could pass). Promoting a stray payload
* would silently surface as a phantom "Restored from local storage" toast
* on the next edit, so we reject anything that doesn't carry the fields the
* legacy writers actually produced.
*/
function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
if (decoded == null || typeof decoded !== 'object') return false
const obj = decoded as Record<string, unknown>
switch (kind) {
case 'flow':
// Legacy FlowBuilder wrote { flow, path, selectedId, draft_triggers, ... }.
return obj.flow != null && typeof obj.flow === 'object'
case 'app':
// Legacy AppEditor wrote `encodeState($appStore)`, i.e. the inner App
// value (see `frontend/src/lib/components/apps/types.ts`) — NOT the
// wrapping AppWithLastVersion. It carries `grid`, `fullscreen`,
// `theme`, `unusedInlineScripts`, `hiddenInlineScripts` among other
// fields — any one of those is a strong signal it's actually a
// Windmill app payload.
return (
'grid' in obj ||
'fullscreen' in obj ||
'theme' in obj ||
'unusedInlineScripts' in obj ||
'hiddenInlineScripts' in obj
)
case 'raw_app':
// Legacy RawAppEditor wrote { files, runnables, data }.
return 'files' in obj || 'runnables' in obj || 'data' in obj
}
}
function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown {
const obj = decoded as Record<string, unknown>
switch (kind) {
case 'flow':
// The legacy bundle wrapped the Flow alongside view-state fields
// (selectedId, draft_triggers, ...). The new entry stores only the
// Flow — the view-state lives elsewhere or is re-derived.
return obj.flow
case 'app':
// Legacy stored the App directly.
return obj
case 'raw_app':
// Legacy bundle missed the `summary` field that the new editor adds.
return {
files: obj.files ?? {},
runnables: obj.runnables ?? {},
data: obj.data ?? {},
summary: typeof obj.summary === 'string' ? obj.summary : ''
}
}
}
function newKey(workspace: string, kind: LegacyKind, path: string): string {
return `userdraft/w/${workspace}/${kind}/${path}`
}
function listLocalStorageKeys(): string[] {
const out: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k != null) out.push(k)
}
return out
}
/**
* Run the legacy new-format migration. Idempotent: returns immediately if a
* previous run completed (signalled by `MIGRATION_FLAG`).
*
* The migration is workspace-scoped because the legacy keys had no notion of
* workspace we treat the caller's current workspace as the owner of any
* surviving legacy entries.
*/
export function migrateLegacyUserDrafts(workspace: string): void {
if (typeof localStorage === 'undefined') return
if (!workspace) return
if (localStorage.getItem(MIGRATION_FLAG) !== null) return
try {
for (const key of listLocalStorageKeys()) {
const match = matchLegacyKey(key)
if (!match) continue
const raw = localStorage.getItem(key)
if (raw == null) continue
try {
const decoded = decodeLegacyState(raw)
if (!isPlausibleLegacyValue(match.newKind, decoded)) continue
const value = transformLegacyValue(match.newKind, decoded)
const target = newKey(workspace, match.newKind, match.path)
if (value !== undefined && localStorage.getItem(target) == null) {
// `lastWrittenAt` makes the migrated entry visible to
// `gcUserDrafts`. We stamp it as "now" so a freshly-migrated
// autosave gets the full retention window — sweeping it
// immediately on the first GC pass would lose work the
// legacy migration just rescued.
localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() }))
}
localStorage.removeItem(key)
} catch (e) {
console.error('UserDraft legacy migration: failed to migrate', key, e)
}
}
localStorage.setItem(MIGRATION_FLAG, new Date().toISOString())
} catch (e) {
console.error('UserDraft legacy migration: aborted', e)
}
}
/** Test-only: clear the sentinel so the migration can re-run. */
export function __resetUserDraftLegacyMigrationForTesting(): void {
try {
localStorage.removeItem(MIGRATION_FLAG)
} catch {
// ignore
}
}
-31
View File
@@ -1,31 +0,0 @@
/**
* "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).
*/
import { sendUserToast } from '$lib/toast'
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)
}
@@ -58,8 +58,6 @@
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
import { gcUserDrafts } from '$lib/userDraft.svelte'
import { setContext, untrack } from 'svelte'
import { base } from '$app/paths'
import { Menubar } from '$lib/components/meltComponents'
@@ -424,19 +422,6 @@
$effect(() => {
$workspaceStore && untrack(() => onLoad())
})
$effect(() => {
if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!))
})
// Sweep UserDraft entries that haven't been touched in 30 days. Runs
// once on mount and on a 30-min timer so a single very long session
// also clears out stale autosaves over time. Live entries stamp
// `lastWrittenAt` on every persist, so the sweep only touches truly
// dormant records.
$effect(() => {
gcUserDrafts()
const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000)
return () => clearInterval(interval)
})
$effect(() => {
innerWidth && untrack(() => changeCollapsed())
})
@@ -7,23 +7,18 @@
DraftService
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanValueProperties, orderedJsonStringify, type Value } 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 UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import { UserDraft } from '$lib/userDraft.svelte'
let app = $state(
undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
)
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
let savedApp:
| {
value: App
@@ -31,69 +26,15 @@
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined = $state(undefined)
let redraw = $state(0)
let path = page.params.path ?? ''
// 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 & { draft_only?: boolean; 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
}
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before any descendant reads it. A plain reload
// (no nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('app', path)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
/** 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. */
* navigation races a reload) bail at the next checkpoint after their
* captured token no longer matches. */
let loadAppToken = 0
async function loadApp(): Promise<void> {
const tok = ++loadAppToken
@@ -108,7 +49,6 @@
value: app_w_draft_.value as App,
path: app_w_draft_.path,
policy: app_w_draft_.policy,
draft_only: app_w_draft_.draft_only,
draft:
app_w_draft_.draft?.['summary'] !== undefined // backward compatibility for old drafts missing metadata
? app_w_draft_.draft
@@ -124,104 +64,14 @@
custom_path: app_w_draft_.custom_path
}
// Resolve the app value: backend draft > deployed, then overlay any
// local autosave from UserDraft if present.
const backendApp = app_w_draft.draft
// The editor works off the backend DB draft when present, otherwise the
// deployed version. AppEditor seeds its own in-memory draft handle from
// this value; edits sync back to the DB.
app = app_w_draft.draft
? app_w_draft.summary !== undefined
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & {
draft_only?: boolean
value: any
})
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & {
draft_only?: boolean
value: any
})
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & { value: any })
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & { value: any })
: app_w_draft
const localDraftValue = UserDraft.get<App>('app', path)
const previousMeta = UserDraft.getMeta('app', path)
const newRevs: UserDraftMeta = {
remoteRev: app_w_draft.versions
? app_w_draft.versions[app_w_draft.versions.length - 1]
: undefined,
remoteDraftRev: app_w_draft.draft_created_at
}
currentRevs = newRevs
if (
localDraftValue != undefined &&
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
orderedJsonStringify(cleanValueProperties(backendApp.value))
) {
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)
}
const appPath = backendApp.path
const hasBackendDraft = app_w_draft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.discard('app', path, undefined)
currentRevs = newRevs
app = backendApp
redraw++
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: appPath
})
}
UserDraft.discard('app', path, undefined)
goto(`/apps/edit/${appPath}`)
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
}
if (app_w_draft.draft && !app_w_draft.draft_only && localDraftValue == undefined) {
const reloadAction = () => {
app = app_w_draft
redraw++
}
const deployed = cleanValueProperties(app_w_draft as Value)
const draft = cleanValueProperties(app ?? {})
sendUserToast('app loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
}
$effect(() => {
@@ -245,7 +95,6 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.discard('app', path, undefined)
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
@@ -257,6 +106,7 @@
return
}
diffDrawer?.closeDrawer()
// Explicit user action: delete the DB draft synchronously before reloading.
if (savedApp.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
@@ -264,7 +114,7 @@
path: savedApp.path
})
}
UserDraft.discard('app', path, undefined)
UserDraft.remove('app', path)
goto(`/apps/edit/${savedApp.path}`)
await loadApp()
redraw++
@@ -288,12 +138,6 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#key redraw}
{#if app}
@@ -315,7 +159,6 @@
{diffDrawer}
version={app.versions ? app.versions[app.versions.length - 1] : undefined}
newApp={false}
initialRevs={currentRevs}
replaceStateFn={(path) => replaceState(path, page.state)}
gotoFn={(path, opt) => goto(path, opt)}
>
@@ -4,12 +4,7 @@
import { AppService, DraftService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import {
cleanValueProperties,
orderedJsonStringify,
readFieldsRecursively,
type Value
} 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'
@@ -18,14 +13,7 @@
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 { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
type RawAppDraft = {
files: Record<string, string>
@@ -41,7 +29,6 @@
/** Data configuration including tables and creation policy */
let data: RawAppData = $state({ ...DEFAULT_DATA })
let newPath = $state('')
// let lastVersion = 0
let policy: any = $state({})
let summary = $state('')
@@ -55,57 +42,17 @@
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined = $state(undefined)
let redraw = $state(0)
let path = page.params.path ?? ''
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before the handle is created. A plain reload (no
// nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('raw_app', path)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
// 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.
// Persist the bundle whenever any of the pieces of state changes. The
// handle's setter syncs the change to the DB (debounced); writes equal to
// the seeded baseline are skipped, so loading doesn't write back.
$effect(() => {
const currentFiles = files
if (!currentFiles) return
@@ -124,8 +71,8 @@
}
})
// Reflect an external UserDraft.save into the form. Idempotent; the
// `!files` guard skips the reload window so it doesn't fight loadApp.
// Reflect an external UserDraft.save (e.g. from the AI copilot) into the
// form. Idempotent; the `!files` guard skips the reload window.
$effect(() => {
const d = draftHandle.draft
const currentFiles = files
@@ -170,13 +117,11 @@
}
files = app.value.files
summary = app.summary
// lastVersion = app.version
policy = app.policy
newPath = app.path
}
/** Increments per `loadApp` call. Stale loads (e.g. when picker
* navigation races a draft-discard reload) bail at the next checkpoint
/** Increments per `loadApp` call. Stale loads bail at the next checkpoint
* after their captured token no longer matches. */
let loadAppToken = 0
async function loadApp(): Promise<void> {
@@ -192,20 +137,13 @@
value: app_w_draft_.value as any,
path: app_w_draft_.path,
policy: app_w_draft_.policy,
draft_only: app_w_draft_.draft_only,
draft: app_w_draft_.draft,
custom_path: app_w_draft_.custom_path
}
// The editor works off the backend DB draft when present, otherwise the
// deployed version.
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
const localDraft = draftHandle.draft
const previousMeta = draftHandle.meta
const newRevs: UserDraftMeta = {
remoteRev: app_w_draft.versions
? app_w_draft.versions[app_w_draft.versions.length - 1]
: undefined,
remoteDraftRev: app_w_draft.draft_created_at
}
const backendBundle: RawAppDraft = {
files: backendSource.value?.files ?? {},
runnables: backendSource.value?.runnables ?? {},
@@ -219,88 +157,10 @@
custom_path: backendSource.custom_path ?? app_w_draft.custom_path
}
if (
localDraft != undefined &&
orderedJsonStringify(cleanValueProperties(localDraft)) !==
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 })
}
const appPath = app_w_draft.path
const hasBackendDraft = app_w_draft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(backendBundle, newRevs)
extractRawApp(backendSource)
redraw++
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: appPath
})
}
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 = localDraft.runnables
data = localDraft.data
summary = localDraft.summary
policy = localDraft.policy ?? app_w_draft.policy
newPath = app_w_draft.path
files = localDraft.files
} else {
if (localDraft != undefined) UserDraft.remove('raw_app', path)
extractRawApp(backendSource)
draftHandle.setDraftAndMeta(backendBundle, newRevs)
if (app_w_draft.draft && !app_w_draft.draft_only) {
const reloadAction = () => {
extractRawApp(app_w_draft)
redraw++
}
const deployed = cleanValueProperties(app_w_draft as Value)
const draft = cleanValueProperties({ files, runnables })
sendUserToast('app loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
}
// Seed the handle (no DB write-back) before populating the form so the
// persist effect's first write matches and is skipped.
draftHandle.setInitial(backendBundle)
extractRawApp(backendSource)
}
run(() => {
@@ -322,12 +182,6 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('raw_app', path)
// Drop the in-memory handle state so loadApp sees no local draft
// on the next pass — otherwise the staleness check would compare
// the stale in-memory meta against the freshly fetched backend and
// fire a spurious modal.
draftHandle.setDraftAndMeta(undefined, {})
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
@@ -339,6 +193,7 @@
return
}
diffDrawer?.closeDrawer()
// Explicit user action: delete the DB draft synchronously before reloading.
if (savedApp.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
@@ -347,7 +202,6 @@
})
}
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(undefined, {})
goto(`/apps/edit/${savedApp.path}`)
await loadApp()
redraw++
@@ -371,12 +225,6 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if files}
{#key redraw}
@@ -4,39 +4,24 @@
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { initialArgsStore, 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 UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
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 { notifyRestoredFromLocal } from '$lib/userDraftToast'
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
let version: undefined | number = $state(undefined)
// `initialArgs` is captured once at mount — it's the session's initial
// argument set. The flow draft itself lives in UserDraft and is re-read
// per loadFlow() so picker navigation doesn't reuse the original path's
// state.
// argument set.
const urlArgs = page.url.searchParams.get('initial_args')
let initialArgs = $state({})
@@ -56,21 +41,10 @@
// Derived so client-side nav (breadcrumb) re-keys the handle to the new path.
let flowDraftPath = $derived(page.params.path ?? '')
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before the handle is created — same pattern as
// /flows/add. A plain reload (no nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('flow', flowDraftPath)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
// `useMany` keyed off the reactive `flowDraftPath` re-keys the handle on nav;
// `flowHandle` proxies the current handle so `flowStore` keeps a fixed ref.
const flowHandles = UserDraft.useMany<Flow>(() => [{ itemKind: 'flow', path: flowDraftPath }])
const flowHandle: UserDraftHandle<Flow> = {
const flowHandle: Pick<UserDraftHandle<Flow>, 'draft' | 'setInitial'> = {
get draft() {
return flowHandles[0]?.draft
},
@@ -78,14 +52,8 @@
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)
setInitial(value) {
flowHandles[0]?.setInitial(value)
}
}
@@ -102,12 +70,21 @@
}
}
// While `seeding` is true, writes through `flowStore` seed the in-memory
// draft WITHOUT syncing to the DB — the value was just loaded from the
// backend, so re-binding it must not write it straight back. Real user edits
// (after load) flip `seeding` off and go through the syncing setter.
let seeding = false
export const flowStore: StateStore<Flow> = {
get val() {
return flowHandle.draft ?? emptyFlow()
},
set val(v: Flow) {
flowHandle.draft = v
if (seeding) {
flowHandle.setInitial(v)
} else {
flowHandle.draft = v
}
}
}
const flowStateStore = $state({ val: {} })
@@ -120,37 +97,8 @@
let selectedId: string = $state('settings-metadata')
let nobackenddraft = false
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:
@@ -160,21 +108,16 @@
let flowBuilder: FlowBuilder | undefined = $state(undefined)
let notFound = $state(false)
/** Increments per `loadFlow` call. Each in-flight load checks its captured
* token against this before writing shared state — if a newer load started
* (e.g. picker navigation while a draft-discard reload is in flight),
* token against this before writing shared state — if a newer load started,
* the older promise no-ops at the next checkpoint. */
let loadFlowToken = 0
async function loadFlow(): Promise<void> {
const tok = ++loadFlowToken
loading = true
let flow: Flow
// Builder-dependent setup is captured here and applied AFTER the builder
// remounts (see end of loadFlow): during a reload renderEditor is false,
// so flowBuilder is unmounted and direct calls would no-op.
seeding = true
let draftTriggersToApply: Trigger[] | undefined = undefined
let applyPrimarySchedule = false
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
@@ -203,115 +146,16 @@
}
}
const backendFlow =
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
const localDraft = flowHandle.draft
const previousMeta = flowHandle.meta
const newRevs: UserDraftMeta = {
remoteRev: v,
remoteDraftRev: flowWithDraft.draft_created_at
}
// The editor works off the backend DB draft when present, otherwise the
// deployed version. Seeding (not assigning) avoids writing the
// freshly-loaded value straight back to the DB.
const flow = flowWithDraft.draft != undefined ? flowWithDraft.draft : flowWithDraft
flowHandle.setInitial(flow)
if (localDraft != undefined) {
const localClean = cleanValueProperties(localDraft)
const backendClean = cleanValueProperties(backendFlow)
if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) {
// Local matches backend exactly — silently drop the autosave.
flow = backendFlow
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(backendFlow, newRevs)
} else {
flow = localDraft
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
pendingBaseline = { baseline: backendFlow, 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 })
}
const flowPath = backendFlow.path
const hasBackendDraft = flowWithDraft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !flowWithDraft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(backendFlow, newRevs)
loadFlow()
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flowPath
})
}
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, {})
nobackenddraft = true
loadFlow()
}
})
}
}
} else {
flow = backendFlow
flowHandle.setDraftAndMeta(backendFlow, newRevs)
}
if (flowWithDraft.draft != undefined && !nobackenddraft) {
if (flowWithDraft.draft != undefined) {
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
applyPrimarySchedule = true
draftTriggersToApply = flowWithDraft?.draft?.['draft_triggers']
if (!flowWithDraft.draft_only && localDraft == undefined) {
const deployed = cleanValueProperties(flowWithDraft)
const draft = cleanValueProperties(flow)
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flow.path
})
UserDraft.remove('flow', flowDraftPath)
// UserDraft.remove only clears localStorage. The
// flowHandle's in-memory state still holds the now-
// deleted DB draft + its meta — loadFlow would treat it
// as a local autosave and the staleness check would fire
// a spurious "newer version was deployed" modal because
// remoteDraftRev moved from "defined" to "undefined".
// Drop the in-memory state first.
flowHandle.setDraftAndMeta(undefined, {})
nobackenddraft = true
loadFlow()
}
sendUserToast('flow loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
draftTriggersToApply = undefined
}
await initFlow(flow, flowStore, flowStateStore)
@@ -327,6 +171,8 @@
if (applyPrimarySchedule) flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(draftTriggersToApply)
flowBuilder?.loadFlowState()
// Loading is done — subsequent edits should sync to the DB.
seeding = false
}
$effect(() => {
@@ -335,14 +181,12 @@
page.params.path
if ($workspaceStore) {
untrack(() => {
nobackenddraft = false // fresh nav reconsiders the backend draft
renderEditor = false // remount the builder for the navigated-to flow
loadFlow().catch((e: any) => {
// A failed load must NOT leave renderEditor stuck false — otherwise
// the editor pane disappears and never remounts. Surface the error
// and remount so the user isn't stranded on a blank pane.
// A failed load must NOT leave renderEditor stuck false.
console.error('Failed to load flow', e)
sendUserToast(`Failed to load flow: ${e?.body ?? e?.message ?? e}`, true)
seeding = false
renderEditor = true
})
})
@@ -357,12 +201,7 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('flow', flowDraftPath)
// Drop the in-memory handle state so loadFlow sees no local draft
// on the next pass — otherwise the staleness check would compare
// the stale in-memory meta against the freshly fetched backend and
// fire a spurious modal.
flowHandle.setDraftAndMeta(undefined, {})
// Re-seed from the backend draft (drops the in-memory edits).
goto(`/flows/edit/${savedFlow.draft.path}`)
loadFlow()
}
@@ -373,6 +212,8 @@
return
}
diffDrawer?.closeDrawer()
// Explicit user action: delete the DB draft synchronously (don't rely on
// the debounced autosync) before reloading the deployed version.
if (savedFlow.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
@@ -381,21 +222,12 @@
})
}
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(undefined, {})
goto(`/flows/edit/${savedFlow.path}`)
loadFlow()
}
</script>
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if notFound}
<div class="flex flex-col items-center justify-center h-full">
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>
@@ -6,18 +6,8 @@
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import type { Schema } from '$lib/common'
import {
cleanValueProperties,
decodeState,
emptySchema,
emptyString,
encodeState,
orderedJsonStringify,
readFieldsRecursively,
sendUserToast
} from '$lib/utils'
import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils'
import { goto } from '$lib/navigation'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
@@ -124,62 +114,13 @@
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: '' })
})
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
// Legacy behavior: the URL hash both seeds the editor on load AND stays in
// sync with edits (encoded back into the hash, debounced). Asks the user
// via modal when the URL payload would clobber an existing local autosave.
let urlConflictModalOpen = $state(false)
let pendingUrlPayload: Script | undefined = undefined
// A base64-JSON script payload in the URL hash (e.g. a Fork link) is an
// explicit "open this script" intent: seed the editor with it.
if (urlScript) {
const seeded = { ...defaultScript(), ...urlScript } as Script
const existing = scriptHandle.draft
if (existing) {
const localClean = orderedJsonStringify(cleanValueProperties(existing))
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
if (localClean !== seededClean) {
pendingUrlPayload = seeded
urlConflictModalOpen = true
}
} else {
scriptHandle.draft = seeded
sendUserToast('Loaded from URL')
}
scriptHandle.draft = { ...defaultScript(), ...urlScript } as Script
sendUserToast('Loaded from URL')
}
function onUrlConflictUseUrl() {
if (pendingUrlPayload) {
scriptHandle.draft = pendingUrlPayload
sendUserToast('Loaded from URL')
}
pendingUrlPayload = undefined
urlConflictModalOpen = false
}
function onUrlConflictKeepLocal() {
pendingUrlPayload = undefined
urlConflictModalOpen = false
}
let _urlHashSyncTimeout: number | undefined
$effect(() => {
const draft = scriptHandle.draft
if (!draft) return
// Gate while the conflict modal is open so we don't overwrite the URL
// payload before the user has decided.
if (urlConflictModalOpen) return
readFieldsRecursively(draft)
if (typeof window === 'undefined') return
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
_urlHashSyncTimeout = setTimeout(() => {
const snapshot = $state.snapshot(scriptHandle.draft)
if (!snapshot) return
const url = new URL(window.location.href)
url.hash = encodeState(snapshot)
window.history.replaceState(window.history.state, '', url.toString())
}, 500)
})
// === END TEMP URL-HASH SYNC ===
async function loadTemplate(): Promise<void> {
if (urlScript) return
if (templatePath) {
@@ -257,14 +198,6 @@
})
</script>
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
<LocalDraftStaleModal
open={urlConflictModalOpen}
cause="url"
onLoadLatest={onUrlConflictUseUrl}
onKeepDraft={onUrlConflictKeepLocal}
/>
{#if scriptHandle.draft}
<ScriptBuilder
{initialArgs}
@@ -1,32 +1,19 @@
<script lang="ts">
import { ScriptService, type NewScript, type NewScriptWithDraft, DraftService } from '$lib/gen'
import { ScriptService, type NewScript, type NewScriptWithDraft } from '$lib/gen'
import { initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import {
cleanValueProperties,
encodeState,
orderedJsonStringify,
readFieldsRecursively
} from '$lib/utils'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
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 { notifyRestoredFromLocal } from '$lib/userDraftToast'
import { UserDraft, type UserDraftHandle } from '$lib/userDraft.svelte'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
@@ -40,8 +27,8 @@
let hash = $derived(page.url.searchParams.get('hash') ?? undefined)
// When viewing a specific historical hash we don't want to load or write a
// local draft — that view is read-only relative to drafts.
// When viewing a specific historical hash we don't want a draft — that view
// is read-only relative to drafts.
let draftPath = $derived(hash ? '' : (page.params.path ?? ''))
// `useMany` keyed off the reactive `draftPath` re-keys the handle on nav;
@@ -49,7 +36,7 @@
const scriptHandles = UserDraft.useMany<EditableScript>(() => [
{ itemKind: 'script', path: draftPath }
])
const scriptHandle: UserDraftHandle<EditableScript> = {
const scriptHandle: Pick<UserDraftHandle<EditableScript>, 'draft' | 'setInitial'> = {
get draft() {
return scriptHandles[0]?.draft
},
@@ -57,14 +44,8 @@
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)
setInitial(value) {
scriptHandles[0]?.setInitial(value)
}
}
@@ -81,14 +62,8 @@
})
/** Some pages base64-JSON-encode a NewScript-like payload into the URL
* hash on `/scripts/edit/<path>#…`. Treat it as a one-shot seed that
* wins over local autosave + backend draft + deployed: apply, toast,
* strip from the URL. Same logic as /scripts/add, kept in this file for
* a faithful mirror of its decoder.
*
* Can't reuse `decodeState` from utils.ts — it fires its own error toast
* on parse failure, which would noise up the UI for unrelated anchors.
*/
* hash on `/scripts/edit/<path>#…` (e.g. Fork links). Treat it as a one-shot
* seed: apply it over the loaded baseline, then strip it from the URL. */
function decodeUrlScriptSeed(): Partial<EditableScript> | undefined {
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
if (!fragment) return undefined
@@ -102,11 +77,7 @@
}
let urlScriptSeed = decodeUrlScriptSeed()
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`
// even when `scriptHandle.draft` is already defined synchronously from a
// local autosave. An empty initialPath flips ScriptBuilder's
// `metadataOpen` heuristic (intended for /scripts/add) into "true" and
// pops the settings drawer open on /edit.
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`.
let initialPath: string = $state(hash ? '' : (page.params.path ?? ''))
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
@@ -121,24 +92,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
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
// Legacy behavior: URL hash both seeds the editor and stays in sync with
// edits. Asks the user via modal when the URL value would clobber an
// existing local autosave that differs from it.
let urlConflictModalOpen = $state(false)
let urlConflictPending: { seed: EditableScript; revs: UserDraftMeta } | undefined = undefined
// Gates the URL-sync effect until the initial URL-seed has been resolved
// (silent apply OR modal closed) so it doesn't overwrite the URL payload
// before the user has decided.
let initialUrlSeedResolved = $state(!urlScriptSeed)
// === END TEMP URL-HASH SYNC ===
function applyBaseline(baseline: EditableScript): void {
initialPath = baseline.path
scriptBuilder?.setDraftTriggers(baseline.draft_triggers)
@@ -149,47 +102,6 @@
}
}
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
}
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
function onUrlConflictUseUrl(): void {
if (urlConflictPending) {
const { seed, revs } = urlConflictPending
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(seed, revs)
applyBaseline(seed)
sendUserToast('Loaded from URL')
}
urlConflictPending = undefined
urlConflictModalOpen = false
initialUrlSeedResolved = true
}
function onUrlConflictKeepLocal(): void {
urlConflictPending = undefined
urlConflictModalOpen = false
initialUrlSeedResolved = true
}
// === END TEMP URL-HASH SYNC ===
/** 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. */
@@ -204,7 +116,7 @@
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
scriptHandle.setInitial({ ...scriptByHash, parent_hash: hash, lock: undefined })
} else {
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
@@ -213,161 +125,26 @@
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptWithDraft))
const localDraft = scriptHandle.draft
const previousMeta = scriptHandle.meta
// The editor works off the backend DB draft when present, otherwise
// the deployed version. Seeding (not assigning) avoids writing this
// freshly-loaded value straight back to the DB.
const backendDraft = scriptWithDraft.draft
? ({ ...scriptWithDraft.draft } as EditableScript)
: undefined
const newRevs: UserDraftMeta = {
remoteRev: scriptWithDraft.hash,
remoteDraftRev: scriptWithDraft.draft_created_at
}
// 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 baseline = (backendDraft ?? (scriptWithDraft as EditableScript)) as EditableScript
const bakedBaseline: EditableScript = {
let bakedBaseline: EditableScript = {
...baseline,
parent_hash: topHash ?? scriptWithDraft.hash
}
if (urlScriptSeed) {
// === TEMP URL-HASH SYNC branch (remove with future PR) ===
// URL hash seed competes with the local autosave on load.
// When they differ, defer to a user-facing modal instead of
// silently overwriting.
const seeded = { ...bakedBaseline, ...urlScriptSeed } as EditableScript
if (localDraft != undefined) {
const localClean = orderedJsonStringify(cleanValueProperties(localDraft))
const seededClean = orderedJsonStringify(cleanValueProperties(seeded))
if (localClean === seededClean) {
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(seeded, newRevs)
initialUrlSeedResolved = true
} else {
urlConflictPending = { seed: seeded, revs: newRevs }
urlConflictModalOpen = true
}
} else {
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(seeded, newRevs)
sendUserToast('Loaded from URL')
initialUrlSeedResolved = true
}
bakedBaseline = { ...bakedBaseline, ...urlScriptSeed } as EditableScript
urlScriptSeed = undefined
// === END TEMP URL-HASH SYNC branch ===
} else if (localDraft != undefined) {
const reference = backendDraft ?? scriptWithDraft
const referenceClean = cleanValueProperties(reference)
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
const hasBackendDraft = !!backendDraft
notifyRestoredFromLocal(hasBackendDraft, !scriptWithDraft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
applyBaseline(bakedBaseline)
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: scriptPath
})
}
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 if (backendDraft) {
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
if (bakedBaseline['primary_schedule']) {
savedPrimarySchedule = bakedBaseline['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
if (!scriptWithDraft.draft_only) {
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: bakedBaseline.path
})
UserDraft.remove('script', draftPath)
// UserDraft.remove only clears localStorage. The
// scriptHandle's in-memory state still holds the now-
// deleted DB draft + its meta — loadScript would treat
// it as a local autosave and the staleness check
// would fire a spurious "newer version was deployed"
// modal because remoteDraftRev moved from "defined"
// to "undefined". Drop the in-memory state first.
scriptHandle.setDraftAndMeta(undefined, {})
goto(`/scripts/edit/${bakedBaseline.path}`)
loadScript()
}
const deployed = cleanValueProperties(scriptWithDraft)
const draft = cleanValueProperties(bakedBaseline)
sendUserToast('Script loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
}
scriptHandle.setInitial(bakedBaseline)
}
if (scriptHandle.draft) {
initialPath = scriptHandle.draft.path
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
scriptBuilder?.setCode(scriptHandle.draft.content)
applyBaseline(scriptHandle.draft)
}
fullyLoaded = true
renderEditor = true
@@ -382,8 +159,7 @@
renderEditor = false // remount the builder for the navigated-to script
loadScript().catch((e: any) => {
// A failed load must NOT leave renderEditor stuck false — otherwise
// the editor pane disappears and never remounts. Surface the error
// and remount so the user isn't stranded on a blank pane.
// the editor pane disappears and never remounts.
console.error('Failed to load script', e)
sendUserToast(`Failed to load script: ${e?.body ?? e?.message ?? e}`, true)
renderEditor = true
@@ -392,31 +168,6 @@
}
})
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
// Mirror the current draft to the URL hash on every edit (debounced).
let _urlHashSyncTimeout: number | undefined
$effect(() => {
const draft = scriptHandle.draft
if (!draft) return
// Wait until the initial URL-seed has been resolved (silent apply or
// modal closed) so we don't clobber the URL payload prematurely.
if (!initialUrlSeedResolved) {
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
return
}
readFieldsRecursively(draft)
if (typeof window === 'undefined') return
if (_urlHashSyncTimeout) clearTimeout(_urlHashSyncTimeout)
_urlHashSyncTimeout = setTimeout(() => {
const snapshot = $state.snapshot(scriptHandle.draft)
if (!snapshot) return
const url = new URL(window.location.href)
url.hash = encodeState(snapshot)
window.history.replaceState(window.history.state, '', url.toString())
}, 500)
})
// === END TEMP URL-HASH SYNC ===
let diffDrawer: DiffDrawer | undefined = $state()
async function restoreDraft() {
@@ -425,12 +176,7 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('script', draftPath)
// Drop the in-memory handle state so loadScript sees no local draft
// on the next pass — otherwise the staleness check would compare the
// stale in-memory meta against the freshly fetched backend and fire
// a spurious modal.
scriptHandle.setDraftAndMeta(undefined, {})
// Re-seed from the backend draft (drops the in-memory edits).
goto(`/scripts/edit/${savedScript.draft.path}`)
loadScript()
}
@@ -441,34 +187,14 @@
return
}
diffDrawer?.closeDrawer()
if (savedScript.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: savedScript.path
})
}
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(undefined, {})
// Delete the DB draft and reload the deployed version.
UserDraft.discard('script', draftPath, undefined)
goto(`/scripts/edit/${savedScript.path}`)
loadScript()
}
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
<!-- TEMP URL-HASH SYNC: conflict modal (remove with future PR) -->
<LocalDraftStaleModal
open={urlConflictModalOpen}
cause="url"
onLoadLatest={onUrlConflictUseUrl}
onKeepDraft={onUrlConflictKeepLocal}
/>
{#if scriptHandle.draft && renderEditor}
<ScriptBuilder
bind:this={scriptBuilder}