refactor(drafts): drop LS-era pipeline; backend is canonical on load

The PR's iteration left behind a meta/staleness pipeline carried over
from the localStorage era — per-rev tracking, a LocalDraftStaleModal, a
'Restored from local storage' toast, and a localDraft-vs-backend
comparison branch in every editor loader. With drafts now living in
the DB and the optimistic-concurrency lastSync check handling
divergence, that whole stack is dead weight.

Worse, the comparison branch caused 'Load from server' in the conflict
modal to do nothing: the loader preferred the in-memory cell over the
backend, so the user-clicked 'load from server' just re-displayed the
local edits AND fired two confusing toasts (Restored from local
storage + Loaded your saved draft).

The rip:

* userDraft.svelte.ts: drop UserDraftMeta, StoredDraft.meta,
  checkStaleness, UserDraftStalenessCause, normalizeForCompare,
  localDraftDiffers, saveMeta, getMeta, setDraftAndMeta, setMeta,
  handle.meta/setDraftAndMeta/setMeta, force option. Handle is now
  just { draft }.
* userDraftToast.ts: drop notifyRestoredFromLocal +
  RestoreFromLocalActions. Update copy.
* LocalDraftStaleModal.svelte: deleted.
* AppEditor.svelte: drop initialRevs prop and the firstMirror
  wipe-then-restore dance (it existed only to consume the meta-mismatch
  skip slot).
* All 4 editor routes: backend is canonical on load — the in-memory
  cell is overwritten with the deployed+draft overlay, the syncer's
  seed guard swallows the first write so we don't POST it back.
* VariableEditor / ResourceEditor: drop the staleness pipeline + rev
  bookkeeping; backend wins on open.
* useTriggerDraftSync.svelte.ts: inline the JSON-normalize + deepEqual
  utility as a private cfgDiffers helper (kept for the form-vs-deployed
  dirty check, which is a genuine semantic compare, not LS legacy).
* copilot core.ts / userDraftAdapter.ts: drop meta argument from
  saveAppDraft, loadAppDraftValue, write*Draft. Test assertions on
  getMeta dropped.

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