From 149edc7bf0e36a5de9e8cf7a1851dcb4eba394b8 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 8 Jun 2026 15:14:03 +0200 Subject: [PATCH] refactor(drafts): drop LS-era pipeline; backend is canonical on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ) --- .../src/lib/components/ResourceEditor.svelte | 98 +----- .../src/lib/components/VariableEditor.svelte | 79 +---- .../components/apps/editor/AppEditor.svelte | 37 +- frontend/src/lib/components/apps/types.ts | 10 - .../LocalDraftStaleModal.svelte | 125 ------- .../copilot/chat/global/core.test.ts | 15 - .../components/copilot/chat/global/core.ts | 68 +--- .../copilot/chat/global/userDraftAdapter.ts | 16 +- .../triggers/useTriggerDraftSync.svelte.ts | 42 ++- frontend/src/lib/userDraft.svelte.ts | 327 +++--------------- frontend/src/lib/userDraftToast.ts | 31 +- .../(logged)/apps/edit/[...path]/+page.svelte | 108 +----- .../apps_raw/edit/[...path]/+page.svelte | 157 +-------- .../flows/edit/[...path]/+page.svelte | 121 +------ .../scripts/edit/[...path]/+page.svelte | 152 +------- 15 files changed, 171 insertions(+), 1215 deletions(-) delete mode 100644 frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 7fed7ace78..13e388da7e 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -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 = $state({}) let fetchedResources: Record = $state({}) let perWsUser: Record = $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 = $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(() => 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('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 @@ } - -
{#if otherDirty.length > 0} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 297dc7ef97..371b67867c 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -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 = $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 = $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(() => 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('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 @@ } - - ) => 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(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(undefined) diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 13e018d8e0..254538c787 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -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. diff --git a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte b/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte deleted file mode 100644 index 94782f176e..0000000000 --- a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - - - -{#if open} - -{/if} diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index bb2990c52c..59814a31c6 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -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 () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index ff1ede4199..c3251538ae 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -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 { 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 +/** + * 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(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(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) }, diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index ee6255a9e3..f0b8ad0568 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -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 = { - 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 = { value: V } & UserDraftMeta - type DraftState = { - val: StoredDraft | undefined + val: V | undefined } type DraftEntry = { @@ -140,7 +94,6 @@ export type UserDraftEntry = { 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(value: V | undefined, meta?: UserDraftMeta): StoredDraft | undefined { - if (value === undefined) return undefined - const out: StoredDraft = { value } - if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev - if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev - return out -} - -function unwrap(stored: StoredDraft | undefined): V | undefined { - return stored?.value -} - -function extractMeta(stored: StoredDraft | 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(value: V | undefined): V | undefined { export type UserDraftHandle = { 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(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( - 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 | 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( - 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( 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 | 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 | 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 | 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(opts?: UserDraftListOptions): UserDraftEntry[] { const ws = resolveWorkspace(opts) @@ -475,14 +275,13 @@ export const UserDraft = { const out: UserDraftEntry[] = [] for (const entry of entries.values()) { if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue - const stored = untrack(() => entry.state.val as StoredDraft | 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 | 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(getSpecs: () => UserDraftSpec[]): UserDraftHandle[] { + useMany( + 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[] { // 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 | 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 | undefined const destroyRoot = $effect.root(() => { - const cell = $state<{ val: StoredDraft | undefined }>({ val: seed }) - stateRef = cell + const cell = $state<{ val: unknown }>({ val: seed }) + stateRef = cell as DraftState // 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 | undefined }>({ val: seed }) + const fallback = $state<{ val: unknown }>({ val: seed }) entries.set(mk, { count: 1, workspace, itemKind, path, - state: fallback, + state: fallback as DraftState, skipNextSync: false, syncSuspended: pendingSuspensions.delete(mk) }) @@ -769,38 +569,11 @@ function makeHandle( const stateOf = (): DraftState | undefined => entries.get(mk)?.state return { get draft(): V | undefined { - return unwrap(stateOf()?.val as StoredDraft | 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 | undefined) - state.val = wrap(value, extractMeta(current)) - }, - get meta(): UserDraftMeta { - return extractMeta(stateOf()?.val as StoredDraft | 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 | 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 } } } diff --git a/frontend/src/lib/userDraftToast.ts b/frontend/src/lib/userDraftToast.ts index 4c2859a948..4a23136403 100644 --- a/frontend/src/lib/userDraftToast.ts +++ b/frontend/src/lib/userDraftToast.ts @@ -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 - /** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */ - onResetToDeployed?: () => void | Promise -} - -/** 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 }> = [] - 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`). diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 6acd699e0e..319cc3ea52 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -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([]) - // 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(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', 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 @@ - {#if $workspaceStore && path} replaceState(path, page.state)} gotoFn={(path, opt) => goto(path, opt)} onResetToDeployed={async () => { diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 0d18b25e71..c65a3bd30a 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -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 @@ - {#if $workspaceStore && path} { - draftHandle.setDraftAndMeta(undefined, {}) + draftHandle.draft = undefined await loadApp({ getDraft: false }) }} /> diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index bc26bf1ec7..33be431128 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -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 @@ - {#if $workspaceStore && flowDraftPath} { - flowHandle.setDraftAndMeta(undefined, {}) + flowHandle.draft = undefined await loadFlow({ getDraft: false }) }} onNavigate={(item) => goto(editPathFor(item))} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index e131bd54e2..c8c40e6831 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -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() } - {#if !hash && $workspaceStore && page.params.path} { - scriptHandle.setDraftAndMeta(undefined, {}) + scriptHandle.draft = undefined await loadScript({ getDraft: false }) }} onDeploy={(e) => {