Compare commits

...
Author SHA1 Message Date
centdix 7c55320f8d docs: align global ai with user draft model 2026-05-20 16:30:23 +02:00
centdix 3d142eded7 docs: tighten global ai userdraft requirements 2026-05-20 15:55:50 +02:00
centdix f7d987a1be docs: add global ai userdraft requirements 2026-05-20 15:51:43 +02:00
centdix 7196568e86 fix: read draft-only scripts in global chat 2026-05-20 15:10:22 +02:00
centdix 63cf688849 refactor: simplify script draft field preservation 2026-05-20 14:42:06 +02:00
centdix fb05c310bb fix: mark global ai drafts explicitly 2026-05-20 14:24:18 +02:00
centdix d42279801e fix: sanitize live script drafts for ai edits 2026-05-19 18:07:00 +02:00
centdix 10d1fde74f fix: resolve add-editor drafts by path 2026-05-19 18:07:00 +02:00
centdix 3e5a6eb454 fix: avoid stale flow drafts in ai listing 2026-05-19 18:07:00 +02:00
centdix ef9e4fe239 nits on instructions 2026-05-19 18:07:00 +02:00
centdix e4ac9abc06 fix: restore global ai prompt writer list 2026-05-19 18:06:34 +02:00
centdix 308666c019 feat: read local userdraft state in global ai 2026-05-19 18:06:34 +02:00
centdix 9d90c91df4 fix: update global drafts item type import 2026-05-19 18:06:02 +02:00
centdix 43be4d8b2d refactor: remove global ai draft fallback store 2026-05-19 18:06:02 +02:00
centdix 110963748e refactor: write global drafts through userdraft 2026-05-19 18:05:19 +02:00
centdix c8418ece27 feat: share trigger drafts with global ai 2026-05-19 18:05:19 +02:00
centdix 972c93a718 feat: share global ai drafts with editors 2026-05-19 18:05:19 +02:00
Diego Imbert 844ec86662 Merge branch 'main' into change-24583b5f 2026-05-19 17:26:35 +02:00
Diego ImbertandClaude Opus 4.7 7612cd7605 docs(frontend): trim rot-prone comments in UserDraft
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:29:52 +02:00
Diego ImbertandClaude Opus 4.7 7c03e1a3b1 refactor(frontend): extract useTriggerDraftSync composable
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:03:41 +02:00
Diego ImbertandClaude Opus 4.7 3d6c4ba7da refactor(frontend): live UserDraft sync for raw app editors
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:58:29 +02:00
Diego ImbertandClaude Opus 4.7 87ba390275 refactor(frontend): live UserDraft handle for trigger editors
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:00:17 +02:00
Diego ImbertandClaude Opus 4.7 9d326358ae fix(frontend): trigger autosave no longer false-restores on plain open
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 23:56:49 +02:00
Diego Imbert 22060b708f feat(frontend): restored-from-local toast in standalone trigger editors
The schedule/postgres/http/kafka/websocket/email/sqs/nats/gcp/azure/
mqtt editors silently overlaid the local UserDraft autosave on top of
the backend config in `openEdit`, with no signal that the form wasn't
showing deployed state. Each now snapshots the just-loaded backend
config, then fires `notifyRestoredFromLocal` with a "Reset to
deployed" action that drops the LS entry and re-applies the snapshot.
2026-05-17 01:00:18 +02:00
Diego Imbert 033d14a69a feat(frontend): staleness modal in resource/variable editors
Resource/variable editors only showed the restored-from-local toast;
they never surfaced the staleness modal when the backend item moved on
since the local autosave was written. Wire LocalDraftStaleModal +
checkStaleness using the backend `edited_at` as `remoteRev` (these
items have no DB-draft concept). Meta is backfilled on reload for
legacy autosaves and seeded on the first real edit via a guarded
effect, so an external edit is detectable as drift. Per-workspace
detection; the modal is a singleton driven by `pendingStale`.
2026-05-17 00:37:32 +02:00
Diego Imbert f233832550 fix(frontend): /apps_raw/add restores autosave on plain reload
The route initialised files/runnables/data/summary to hardcoded
defaults, and the $effect mirror then wrote those defaults over the
persisted empty-path autosave. Seed the $state from
`draftHandle.draft` instead; import/template/hub branches
`UserDraft.remove('raw_app', '')` for explicit "start fresh"
semantics. Also consume useLocalStorageValue's saveInitialValue=false
skip slot (wipe-then-restore) so the user's first edit isn't dropped.
2026-05-17 00:31:57 +02:00
Diego Imbert e16ed3335b fix(frontend): raw app deploy toast crash + harden Toast against bad type
RawAppEditorHeader's catch blocks called `sendUserToast(msg, e)`,
passing an Error as the `_type` arg. `classes[<Error>]` is undefined so
`color.descriptionClass` threw — and because the toast renders in the
root layout, it crashed the whole page on raw app deploy/create. Fixed
both call sites to the proper `(msg, true)` error form.

Also hardened Toast.svelte: coerce any non-AlertType `type` to 'error'
so a future miscall degrades to a plain error toast instead of taking
down the page.
2026-05-17 00:31:51 +02:00
Diego Imbert 36ce4c6d44 fix(frontend): snapshot UserDraft proxy before structuredClone in resource save
`states[ws].draft` is now a Svelte $state proxy (it flows through
UserDraft's useLocalStorageValue cell). `structuredClone` can't clone a
proxy and threw "Failed to execute 'structuredClone' on 'Window'",
blocking resource saves. Snapshot to a plain object via
`$state.snapshot` before assigning the dirty baseline.
2026-05-17 00:31:44 +02:00
Diego Imbert 3ba62c0ee2 nit rename 2026-05-15 18:34:53 +02:00
Diego Imbert 65730f346e fix(frontend): /flows/add restores autosave on plain reload
`loadFlow()` initialised the local `flow` variable to `emptyFlow()`,
then passed it to `initFlow` which writes it to `flowStore.val` (=
`flowHandle.draft = flow`). On a bare /flows/add reload (no
template/hub/import/fork/urlHash) the assignment overwrote the
persisted autosave with the empty baseline. Seed `flow` from
`flowHandle.draft` instead, and keep `emptyFlow()` as the explicit
"start fresh" baseline for template/hub branches.
2026-05-15 18:34:36 +02:00
Diego Imbert 6620ef1fbd fix(frontend): use UserDraft.discard in apps reset flows
The apps editor route doesn't hold the UserDraft handle — AppEditor
(the child remounted by {#key redraw}) does. When a reset action ran
`UserDraft.remove` + `redraw++`, Svelte could mount the new AppEditor
before the old one's onDestroy released its handle, leaving the
entry's in-memory state.val populated with the stale autosave. The
new AppEditor would then re-acquire that entry and shadow the
just-emptied localStorage.

Switch every reset path (stale modal Load latest, restored-from-local
toast, DiffDrawer restoreDraft/restoreDeployed) to `UserDraft.discard`
so the in-memory cell is cleared synchronously alongside LS. Also
plumb `currentRevs` updates so the next mount's initialRevs reflects
the acked state.
2026-05-15 18:19:50 +02:00
Diego Imbert 118b0c4e90 fix(frontend): add UserDraft.discard so "Reset to deployed" doesn't re-persist
The "Reset to deployed" toast action in resource/variable editors
called UserDraft.save with the backend value to repaint the form. That
left a duplicate-of-backend autosave in localStorage which would
silently restore on every subsequent reload, defeating the reset.

New UserDraft.discard(itemKind, path, fallback) clears LS AND resets
any live handle's in-memory state to the fallback, skipping the next
persist so the fallback doesn't round-trip back into storage. Backed
by a new `skipNextWriteOnce()` method on useLocalStorageValue's return.
2026-05-15 18:13:01 +02:00
Diego Imbert 066149c6fb feat(frontend): restored-from-local toast in resource/variable editors
Resource and variable editors silently loaded LS autosaves over the
backend value, leaving users with no signal that the form wasn't
reflecting deployed state. Both now fire the standard
`notifyRestoredFromLocal` toast (with a "Reset to deployed" action
that re-seeds the handle from the just-fetched backend) the first
time a lazy-fetch finds the local draft diverging from the remote.
2026-05-15 17:58:49 +02:00
Diego Imbert 24c09ee99b fix(frontend): apps detect drift + restore on /apps/add reload
Two related issues in the app editor's UserDraft wiring:

1. Drift wasn't detected on first deploy/draft after starting an
   autosave. The route only backfilled meta on a reload that found a
   local diff — so the first external change after editing slipped
   through with empty `previousMeta`. AppEditor now receives the
   load-time revs as `initialRevs` and seeds them into the handle's
   meta on the first mirror, capturing the rev at autosave-creation
   time.

2. /apps/add didn't restore from LS on plain reload. The route
   always initialised `value` to `emptyApp()` and the AppEditor's
   `stateApp` captured the prop unconditionally, so the LS autosave
   was shadowed. `stateApp` now falls back to `appDraftHandle.draft`
   when present; the template/hub/import branches explicitly
   `UserDraft.remove('app', '')` to keep "start fresh from this
   content" semantics.

Also work around `useLocalStorageValue`'s `saveInitialValue: false`
skip slot — in the mirror pattern the slot survived past mount and
swallowed the user's first edit. Consume it up-front with a
wipe-then-restore pair so subsequent edits persist normally.
2026-05-15 17:55:21 +02:00
Diego Imbert 6406d4ad69 fix(frontend): untrack meta-preservation reads in UserDraft setters
`set draft`, `setMeta`, `UserDraft.save`, and `UserDraft.saveMeta` all
read `state.val` before writing it (to preserve existing rev metadata).
When called from inside a `$effect` — as AppEditor does to mirror its
reactive `$state` into the handle — the read subscribes the effect to
the entry's `$state` cell that the write then mutates, producing an
`effect_update_depth_exceeded` loop. Wrap the reads in `untrack` so
mirrors don't self-trigger.
2026-05-15 17:30:13 +02:00
Diego Imbert 7d93afbbd8 fix(frontend): legacy app migration matches actual stored shape
Legacy AppEditor wrote `encodeState($appStore)` — the inner App value
(grid/fullscreen/theme/unusedInlineScripts/hiddenInlineScripts), not the
wrapping AppWithLastVersion. The plausibility check was matching the
wrapping fields, so real legacy app entries were filtered out and never
migrated to the new userdraft/w/{ws}/app/{path} keys.
2026-05-15 16:56:20 +02:00
Diego Imbert 202b3f69d8 fix(frontend): drop in-memory handle in DiffDrawer restoreDraft/restoreDeployed
Same UserDraft.remove-without-clearing-in-memory bug as the previous
two commits, this time in the DiffDrawer's "Restore to draft" /
"Restore to deployed" buttons on all four /edit routes. The handler
deletes the DB draft (in the deployed case), wipes the localStorage
entry, navigates, and reloads — but the route's UserDraft handle
still holds the old draft + meta in memory, so the reload's
staleness check compares the stale meta against the freshly fetched
backend and surfaces a spurious "newer version was deployed" modal.

- scripts/edit, flows/edit, apps_raw/edit: route-level handle —
  `handle.setDraftAndMeta(undefined, {})` before the reload.
- apps/edit: the handle lives in the AppEditor child, so force a
  remount by setting `app = undefined; redraw++` before goto/loadApp
  (matches the existing pattern from the toast's onResetToDeployed).
2026-05-15 15:41:26 +02:00
Diego Imbert fec207cc99 fix(frontend): drop in-memory handle before reloading after DB-draft discard
When the "Script/flow loaded from latest saved draft" toast's
"Reset to deployed" action ran, it:
1. Deleted the DB draft via DraftService.deleteDraft.
2. Called UserDraft.remove (clears localStorage only).
3. Called goto + loadScript / loadFlow.

But the handle's in-memory state still held the now-deleted DB draft
and its meta (remoteDraftRev pointing at the gone draft's created_at).
On the reload, the editor's loadScript/loadFlow saw `localDraft !=
undefined` and ran the staleness check, which compared
`meta.remoteDraftRev = <old timestamp>` against
`currentDraftRev = undefined`. Verdict: "version" stale → spurious
"A newer version was deployed on the server" modal, even though
nothing on the server actually moved. The editor visibly froze
behind the modal because the in-memory state wasn't refreshed.

Drop the in-memory state with `handle.setDraftAndMeta(undefined, {})`
before the reload — same fix already applied to the
"Restored from local storage > Reset to deployed" toast action.

apps/edit and apps_raw/edit's "discard draft" actions don't call
DraftService.deleteDraft (they just swap the in-memory view to the
deployed branch), so they don't hit this codepath.
2026-05-15 15:35:00 +02:00
Diego Imbert 88e6050c33 nit 2026-05-15 15:27:51 +02:00
Diego Imbert 5b6dc32ee7 fix(frontend): untrack the splice's own .length read in useMany reconcile
The previous fix wrapped only the comparison reads in `untrack`, but
`handles.splice(0, handles.length, ...next)` still reads `.length`
under the effect's tracking scope — same feedback loop, same
`effect_update_depth_exceeded`.

Move the whole "compare + splice" block inside `untrack`. The
downstream notification on splice still fires (untrack suppresses
dependency subscriptions on the producer side, not write
notifications), so consumers of `handles` still re-render.
2026-05-15 15:19:18 +02:00
Diego Imbert 7ccb2b7941 fix(frontend): break useMany reconcile feedback loop
The reconcile effect read `handles.length` / `handles[i]` for the
"unchanged?" early-exit optimisation and then `handles.splice(...)`
to publish the new array. Reading `handles` inside the effect
registered it as a dependency; the subsequent splice re-fired the
effect; ad infinitum (Svelte threw
`effect_update_depth_exceeded`).

Wrap the comparison reads in `untrack` so the effect's only
tracked dependency stays `getSpecs()`. The splice still fires the
downstream readers of `handles` (the whole point of `useMany`'s
reactivity); it just doesn't re-enter its own producer.
2026-05-15 15:19:18 +02:00
Diego Imbert 2d432c6308 feat(frontend): GC UserDraft entries older than 30 days
Without a sweep, a heavy user accumulates one localStorage entry per
(workspace, kind, path) they ever touched. The pre-PR single-key
autosave self-capped at one entry per editor; this one needs an
explicit GC pass.

Mechanism:
- Stamp every persist with `lastWrittenAt: Date.now()`. Added at four
  sites: `useLocalStorageValue`'s new `transformBeforePersist`
  option (covers both setter and deep-mutation persists),
  `UserDraft.save`'s no-handle fallback, `persistDirect` (force-meta
  writes), and the legacy migration. Done at persist time, not in
  `wrap()`, so deep mutations bump the clock too — `wrap()` runs only
  on `.draft =` assignments, which would leave the timestamp stale for
  bind-mutated editor sessions.
- `gcUserDrafts(maxAgeMs = 30d)` walks every `userdraft/w/...` key,
  removes the ones older than the cutoff. Entries written before this
  field existed (pre-PR or pre-this-commit) get backfilled with the
  current time on first sweep so a 30-day clock starts fresh; the
  alternative — sweeping on sight — would wipe work that the legacy
  migration just rescued.
- Wired into the logged-in layout: runs once on mount and every 30 min
  via `setInterval` (cleaned up in the effect's return).

Tests use `vi.setSystemTime` to drive the clock; assertions on the
stored payload now go through a `storedShape` helper that strips
`lastWrittenAt` before string-comparing, so the existing
`expect(...).toBe(wrapped(...))` style still reads cleanly. New tests
cover the sweep, the backfill behaviour, the default 30d window, and
a custom `maxAgeMs`.
2026-05-15 15:19:18 +02:00
Diego Imbert 65b5be8eff fix(frontend): wire ?nodraft=true to actually skip the local autosave on /edit
The flows/apps/apps_raw `/edit` routes had a `?nodraft=true` handler
that just stripped the param from the URL via `afterNavigate` —
nothing behind it. The original pre-PR semantics (and what every
caller assumes) was "skip the localStorage autosave on this load."

Mirror the synchronous wipe pattern already in /add: when nodraft is
present, call `UserDraft.remove(kind, path)` and strip the flag from
the URL via `window.history.replaceState`, before the UserDraft handle
is created. The handle then reads an empty entry and the editor opens
on the backend version. A plain reload (no nodraft) restores the
autosave normally.

Removed the redundant `afterNavigate` blocks. Dropped the now-unused
`afterNavigate` import in all three; apps/edit still imports
`replaceState` (used downstream), so only that name stayed.
2026-05-15 15:19:18 +02:00
a5faec975e chore(frontend): drop leftover console.log in setDraftConfig
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 12:47:17 +00:00
Diego Imbert e649c2b89f fix(frontend): isolate UserDraft entries via $effect.root
The previous commit landed `useMany` calling `useLocalStorageValue`
directly. That works for the `use()` 1-spec wrapper (whose getter is
untracked, so the reconcile `$effect` never re-runs), but for dynamic
specs (ResourceEditor / VariableEditor) it leaks the persist `$effect`
into the reconcile `$effect`'s scope — meaning the second spec change
would destroy the first entry's deep-mutation persist loop.

Wrap the `useLocalStorageValue` creation in `$effect.root` so the
entry's reactivity lives in its own scope. Stash the returned
disposer on the entry and invoke it when the refcount hits 0.

The vitest runtime's `$effect.root` returns its disposer but never
runs the callback (a test-env quirk, not a production behaviour).
Kept a documented fallback that calls `useLocalStorageValue` directly
when the callback doesn't populate `stateRef`. In tests that path
parents the persist `$effect` to the test scope and lives long
enough; in production `$effect.root` runs the callback synchronously
per the Svelte 5 spec and the fallback is unreachable.
2026-05-15 10:55:18 +02:00
Diego Imbert d65db23787 refactor(frontend): replace UserDraft.release() with useMany()
Public surface change:
- New `UserDraft.useMany(getSpecs: () => UserDraftSpec<V>[])` returns a
  reactive array of handles. The reconcile loop acquires entries for
  added specs, releases entries for removed specs, and re-uses cached
  handles for unchanged keys so caller-captured references stay stable.
- `UserDraft.use(kind, path, opts?)` becomes a 1-len wrapper around
  `useMany`. The spec getter is `untrack`ed so reactive opts
  (`$workspaceStore` etc.) are still captured-once — current `use()`
  semantics unchanged.
- `UserDraftHandle.release()` and the `manualRelease` option are gone.
  Component teardown is handled by a single internal `onDestroy` that
  releases every entry `useMany` acquired.

ResourceEditor + VariableEditor migrated:
- Replaced `Record<ws, Handle>` + manual `ensureHandle`/`release` with
  a `workspaceSpecs: $state<Array<{ws, defaultValue}>>` plus a
  derived `Record<ws, Handle>` that pairs each ws with its parallel
  handle from `useMany`. `ensureHandle(ws)` is now just a push to
  the specs array; `VariableEditor.reset()` clears it. The reconcile
  loop handles acquisition/release end-to-end.

Tests:
- Dropped the `manualRelease`/`release` test; the option no longer
  exists.
- Added a `useMany` test asserting per-spec entries, isolated
  workspace-scoped localStorage keys, and a single onDestroy
  registration covering every acquired entry.

Implementation note: I tried wrapping `useLocalStorageValue` in
`$effect.root` to give the entry's `$state`/`$effect` an independent
scope (in case `useMany`'s reconcile effect tore down nested effects
across cycles). But `$effect.root`'s callback wasn't running
synchronously in the test runtime (vitest + svelte-vite plugin), and
the original `use()` implementation called `useLocalStorageValue`
directly without issue. Reverted to the direct call; the
nested-scope concern stays theoretical.
2026-05-15 10:39:03 +02:00
Diego Imbert 5c2209dc93 docs: review nits — variable.edited_at backfill, UserDraft toast/modal headers
Three low-priority callouts:

- Document the variable.edited_at backfill in the migration. All
  existing rows get a single `now()` timestamp from the column
  DEFAULT; the staleness check only consumes the field as an opaque
  rev string and never displays/sorts on it, so the collision is
  harmless — but worth saying out loud.
- Add module headers to userDraftToast.ts and LocalDraftStaleModal.svelte
  explaining how this layer sits above the per-browser UserDraft
  autosave and is distinct from the backend DraftService (the
  server-side "Save as draft" feature surfaced as `*.draft`).
2026-05-15 03:08:59 +02:00
Diego Imbert 1a27f56686 refactor(frontend): drop dead updateDraftCallback from Triggers constructor
None of the eight `new Triggers(...)` call sites pass an update
callback any more — the bridge was a leftover from the pre-UserDraft
era when ScriptBuilder ran its own localStorage autosave and had to
be notified on every triggers mutation. The unified UserDraft handle
now deep-tracks `script.draft_triggers` via the $effect in
ScriptBuilder, so the callback channel is dead weight.

Removes the third constructor parameter, the private field, and the
six `this.#updateDraftCallback?.()` invocations across setters and
mutators.
2026-05-15 03:02:56 +02:00
Diego Imbert 655827ffa3 docs(frontend): clarify staleness modal copy
The four route-level editors (scripts/flows/apps/apps_raw) keep the
user's local draft visible behind the modal so they can glance at it
before choosing. The old body text described the situation (server
has moved on, local autosave is behind) but didn't say what's
actually on screen or how each action maps to it.

New body leads with "The editor is showing your local autosave" and
spells out each action: "Load latest replaces what's on screen; Keep
current leaves it alone." Same copy for both `cause = 'draft'` and
`cause = 'version'`, branching only on what the user is "behind"
relative to.
2026-05-15 03:01:05 +02:00
Diego Imbert 397256af1e fix(backend): drop AT TIME ZONE 'UTC' from draft.created_at migration
The original migration forced `USING created_at AT TIME ZONE 'UTC'`,
which tags every existing wall-clock value as UTC. That matches the
common case (Postgres on a UTC server, which the Docker image and most
managed offerings default to), but on a non-UTC operator's deployment
it shifts all pre-migration timestamps by the server's tz offset.

Drop the USING clause. Postgres's default `TIMESTAMP -> TIMESTAMPTZ`
cast reinterprets each existing value in the session's current
timezone — which is the same timezone under which the original
`INSERT ... DEFAULT now()` values were truncated to TIMESTAMP, so
the conversion correctly recovers the original instant regardless of
the operator's timezone. Same semantics on UTC servers, correct
semantics on non-UTC servers.

Down migration updated symmetrically.
2026-05-15 02:56:38 +02:00
Diego Imbert 30a0eeda61 fix(frontend): tighten legacy-migration key matching
The legacy migration was consuming any localStorage key starting with
`app-`, `flow-`, or `rawapp-`, with no constraint on what followed and
no shape check on the decoded payload. Two failure modes called out
in review:

1. A future feature (or third-party extension) picking a name like
   `app-recent` would silently lose data on first migration run.
2. A stray key that happened to base64-decode to valid JSON but
   wasn't a real legacy draft would still get promoted to the new
   format, surfacing later as a phantom "Restored from local storage"
   toast on the next edit.

Two guards:

- `LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/`: after a `<prefix>-` match,
  the remainder must look like a Windmill item path (`u/owner/name`
  or `f/folder/name`, possibly with deeper segments). Bare-prefix
  empty-path entries (`app` / `flow` / `rawapp` for `/add` autosaves)
  still match the exact branch and don't go through the shape gate.
- `isPlausibleLegacyValue`: after decode, require the payload to
  carry the field the legacy writers actually produced
  (`flow.flow` for flows, any of `summary|value|policy|path` for
  apps, any of `files|runnables|data` for raw apps).

Both are belt-and-suspenders: nothing else currently uses these key
prefixes, but enforcing the shape locally keeps the migration safe
against future namespace collisions.
2026-05-15 02:45:56 +02:00
Diego Imbert 9884f061e2 feat(frontend): debounce option on useLocalStorageValue + 500 ms in UserDraft.use
Adds `debounce: number` to `useLocalStorageValue`'s options. When set,
repeated mutations within the window collapse into a single
localStorage write fired by a plain `setTimeout`. The in-memory
`$state` is updated on every change so readers of `.val` always see
the latest value; only the persistence side-effect is deferred.

No `onDestroy` flush — the timer is independent of the Svelte
lifecycle, so SPA route teardown doesn't drop the pending write
(the callback still fires later as long as the JS context is alive).
A hard browser tab close within the window does drop it; that's an
acceptable trade-off vs the complexity of `beforeunload` listeners
and the leak/refcount issues they create alongside `useLocalStorageValue`'s
keyed instances.

`UserDraft.use` opts in with `debounce: 500` so a typing storm in the
script/flow/app editor produces one localStorage write per 500 ms
instead of one per keystroke.

Tests switch to `vi.useFakeTimers()` and a `flushPersist()` helper to
keep the synchronous `expect(localStorage…)` assertions working. New
test verifies the coalescing behaviour end-to-end.
2026-05-15 02:43:03 +02:00
Diego Imbert 664cf5598a fix(frontend): persist trigger drafts in script-editor autosave
The triggers in ScriptBuilder live in a dedicated `triggersState`
$state, separate from the `script` object that the UserDraft handle
deep-tracks. Pre-PR the per-builder localStorage autosave bridged the
two by snapshotting `triggersState.getDraftTriggersSnapshot()` into
the payload on every write — that bridge was dropped when we removed
the per-builder autosave in favour of UserDraft.

Add an $effect that deep-reads triggersState and mirrors the snapshot
back into `script.draft_triggers`. The UserDraft handle (already
deep-tracking `script`) then persists the trigger drafts as part of
the script autosave, restoring the prior behaviour.
2026-05-15 02:33:19 +02:00
Diego Imbert 2c62d9e1fb Merge remote-tracking branch 'origin/main' into change-24583b5f 2026-05-15 01:53:11 +02:00
Diego Imbert d1f102d750 fix(backend): refresh draft.created_at on every upsert
The draft upsert was `ON CONFLICT (...) DO UPDATE SET value = EXCLUDED.value`,
so subsequent draft writes left `created_at` frozen at the first INSERT.
The frontend's UserDraft staleness check reads that timestamp as
`remoteDraftRev`; with it frozen, an updated remote draft looked
identical to the originally-baselined one and the "newer draft was
saved on the server" modal never fired.

Touch `created_at` on conflict too. The column's semantic widens from
"first write time" to "last write time", which is what every reader of
the field actually wants — the staleness signal is the only consumer.

SQLx offline cache regenerated to match the new query text.
2026-05-15 01:43:09 +02:00
Diego Imbert a9939af90a fix(frontend): settings drawer auto-opening on /scripts/edit
ScriptBuilder's metadataOpen flag fires when `initialPath == ''` (the
heuristic for "new script, expected on /scripts/add"). The route's
`let initialPath = $state('')` left it empty until applyBaseline ran
later inside loadScript.

Pre-PR, the editor was gated on a route-level `script` $state that
started undefined, so ScriptBuilder didn't mount until loadScript's
synchronous block set both `script` and `initialPath` in the same
tick. With UserDraft.use reading localStorage synchronously, the gate
(`scriptHandle.draft`) is satisfied at mount time and ScriptBuilder
mounts with the still-empty initialPath, popping the drawer open.

Seed initialPath from page.params.path synchronously so ScriptBuilder
sees the path on its first render. Falls back to '' for the historical
`?hash=` view to preserve the existing behaviour there.
2026-05-15 01:38:27 +02:00
Diego Imbert 3132a0207a fix(backend): convert draft.created_at to TIMESTAMPTZ
The new `*WithDraft` endpoints surface `draft.created_at` as
`Option<chrono::DateTime<Utc>>` for the frontend's staleness check,
which requires `TIMESTAMPTZ`. The column was originally created as
plain `TIMESTAMP`, so SQLx fails to deserialize any row that has a
non-null draft and the handler returns HTTP 400 instead of 200 —
caught by `test_draft_endpoints` in the integration tests.

Migrate the column to `TIMESTAMPTZ`, interpreting existing values as
UTC (matching `now()`'s behaviour on a UTC server). No compile-time
sqlx queries reference the column, so the offline cache stays valid.
2026-05-15 01:35:38 +02:00
Diego Imbert b523427473 fix(frontend): "Reset to deployed" loop on Restored-from-local toast
UserDraft.remove only clears localStorage — the entry's reactive cell
stays alive as long as some component holds a handle. The toast
callback was relying on remove+loadXxx to reset state, but loadXxx
then read the *in-memory* autosave through the still-alive entry,
matched it against the now-deployed reference, and re-fired the same
toast. Forever.

Drop the in-memory state explicitly before the load:
- scripts/flows/apps_raw (route-level handle): `handle.setDraftAndMeta(undefined, {})`
- apps (handle lives in the AppEditor child): set `app = undefined`
  to unmount AppEditor — its onDestroy releases the handle and the
  entry's refcount drops to 0, destroying the entry.

ScriptBuilder / FlowBuilder / RawAppEditor briefly unmount while the
reload fetches; the flash is the user-visible "loading" cue.
2026-05-13 19:40:48 +02:00
Diego Imbert 3c3bd54148 feat(frontend): load URL-encoded scripts on /scripts/edit + consume-once
Mirror the URL-hash seed mechanism from /scripts/add to /scripts/edit
for parity: decode the base64-JSON-encoded NewScript payload from the
URL hash, apply it over the bakedBaseline as the editor's initial
state, send "Loaded from URL", and strip the hash immediately via
window.history.replaceState so a reload restores the user's autosave
rather than re-injecting the seed.

The seed wins over local autosave + backend draft + deployed —
UserDraft.remove(script, draftPath) drops the stale autosave on disk
before setDraftAndMeta writes the seeded value, so the user's
subsequent edits will overwrite cleanly.

Skipped when ?hash= is in the URL (historical-version view, which is
read-only relative to drafts) and when the hash fragment isn't a
parseable encoded payload.

No callers build /scripts/edit#<encoded> URLs today — this lands the
mechanism for future symmetry with /scripts/add.
2026-05-13 19:32:54 +02:00
Diego Imbert 13c0237a81 feat(frontend): strip URL hash from /scripts/add after consumption
The URL-encoded script is a one-shot seed (Fork preview, workspace
handler templates, hub publish) — keeping the hash in the bar after
loading meant a reload would re-apply the original payload and wipe
whatever the user edited since landing.

After applying `urlScript` and firing the "Loaded from URL" toast,
clear `location.hash` via `window.history.replaceState`. The user's
edits then flow into the normal autosave path (UserDraft empty-path
entry), and a reload restores those edits instead of the seed.
2026-05-13 19:22:38 +02:00
Diego Imbert 33aecebd79 feat(frontend): load URL-encoded scripts on /scripts/add
The "Fork" action on run/[...run] and several workspace-settings
helper-script templates base64-JSON-encode a NewScript into the URL
hash on `/scripts/add#...`. Until now /scripts/add silently dropped
that payload — both call sites landed on a blank editor.

Decode `page.url.hash` at module top, and if it parses to an object,
apply it as `scriptHandle.draft` and surface "Loaded from URL". The
URL value wins over local autosave, ?template, ?hub, and YAML imports
because the hash represents an explicit "open this script" intent.

Parsing is inlined rather than reusing `decodeState` so an unrelated
hash (e.g. a future route anchor) doesn't fire its default "Impossible
to parse state" error toast.
2026-05-13 19:00:19 +02:00
Diego Imbert a70a378cd1 feat(frontend): toast when editor opens on a local autosave
When a route loads its local autosave (differs from backend, no
staleness alarm), surface "Restored from local storage" with up to
two reset actions:
- "Reset to saved draft": drop the autosave, reapply the backend DB
  draft. Only shown when the backend has a DB draft.
- "Reset to deployed": drop the autosave, delete the DB draft on the
  backend (if any), reload from the deployed version. Only shown when
  the item has a deployed version.

The toast title + label wording + per-state inclusion live in a
single helper (`$lib/userDraftToast`). Each editor passes its own
reset callbacks since the side effects differ per route (handle vs
UserDraft.get/save, redraw counters, loadXxx helpers).

Wired to scripts/edit, flows/edit, apps/edit, apps_raw/edit. Resource
and variable editors don't have DB drafts and use per-workspace
handles — a follow-up will tailor a single-action version.
2026-05-13 18:54:29 +02:00
Diego Imbert 7acc03b250 fix(frontend): strip ?nodraft=true from /add URLs synchronously
The previous cleanup ran in afterNavigate, which (a) fires asynchronously
— a quick reload between mount and the callback would re-wipe the
freshly-started draft — and (b) did `url.search = ''`, nuking sibling
params like ?template, ?hub, and ?wac.

Move the URL cleanup to the same synchronous block that calls
UserDraft.remove on nodraft, using `window.history.replaceState` so it
lands before paint. Only the `nodraft` key is removed — other params
survive.
2026-05-13 18:26:38 +02:00
Diego Imbert 70263b184f docs(frontend): refresh stale "in-memory only" comments around empty paths
Empty-path UserDraft entries persist now. Drop the leftover "in-memory
only" comments on the /add pages' handle creation, and rewrite the
EditorHeader save-initial-draft comments to describe why the UserDraft.remove
call is still needed: the draft was promoted to a real path on the
backend, so the prior-path autosave must not shadow a future "+ App" /
"+ Flow" / … visit.
2026-05-13 18:20:25 +02:00
Diego Imbert 145c72c970 refactor(frontend): drop legacy-migration shape guard
We assume Windmill is the only app on the origin, so the
isPlausibleLegacyValue per-kind shape check was just dead weight.
Keep the cheap "decoded is an object" guard for malformed payloads.
2026-05-13 17:52:10 +02:00
Diego Imbert e7e586997c feat(frontend): persist empty-path drafts across reloads
Empty paths used to be in-memory only (via the `isLocalOnly` short-circuit)
because we worried about collisions between concurrent /add tabs. The user
asked for the trade-off to flip: a /flows/add or /scripts/add reload should
restore the user's work, while explicitly clicking "+ Flow / + Script / …"
should always open a clean editor.

- Drop `isLocalOnly` from UserDraft so empty-path entries persist under
  `userdraft/w/{ws}/{kind}/` like any other path. The existing per-kind
  refcounting and saveInitialValue=false behavior already handle them
  correctly — the change is just lifting the bypass.
- Each /add page now calls `UserDraft.remove(kind, '')` synchronously
  when `?nodraft=true` is present in the URL, before the handle is
  created.
- The two "+" entry points that lacked the `?nodraft=true` flag
  (CreateActionsScript's plain `<a href>` and CreateActionsFlow's
  YAML/JSON import paths) now include it, so every fresh-start path goes
  through the wipe.
- Tests updated: the "empty path (in-memory only)" block becomes
  "empty path (persists across reloads)" and asserts the new behavior.
2026-05-13 17:31:54 +02:00
Diego Imbert 42e97bb05a refactor(frontend): seed per-workspace handles via UserDraft.use defaultValue
ensureHandle was doing a post-hoc `if (h.draft === undefined) h.draft = baseline`,
which relies on the saveInitialValue=false skip to swallow that seeding
write. Hand the baseline to `UserDraft.use({ defaultValue })` instead —
useLocalStorageValue uses it as the initial $state value when localStorage
is empty, so lastSerialized is correct out of the gate and no setter call
is needed.
2026-05-13 16:00:37 +02:00
Diego Imbert 3301d81ed6 refactor(frontend): per-workspace UserDraft handles in Resource/Variable editors
Earlier commits in this PR wired the resource and variable editors to a
single multi-workspace bundle stored under the user's session workspace
key — which mixed workspaces in one localStorage entry and required a
custom multi-key fix-up pass to persist edits for other workspaces.

Reset both editors to their pre-PR shape and apply the minimal change:
the per-workspace `Record<string, ResourceState>` (resp. `VariableState`)
becomes `Record<string, UserDraftHandle<…>>`, with one handle per
workspace created via `UserDraft.use(…, { workspace: ws })`. The handle
keys its own localStorage entry under that workspace, so cross-workspace
edits stay cleanly separated and reactivity flows through the handle's
`draft` accessor — `bind:` on form fields just works.

Adds `manualRelease: true` + `handle.release()` to `UserDraft.use` so
the editors can register handles lazily inside an effect (Svelte 5
forbids `onDestroy` outside component init). The editors register a
single top-level `onDestroy` that releases every collected handle.

After a successful save, the per-workspace autosave is cleared via
`UserDraft.remove(itemKind, path, { workspace })`.
2026-05-13 15:40:17 +02:00
Diego Imbert 06b01dfe36 nit remove comments 2026-05-13 15:33:36 +02:00
Diego Imbert 6d73345c4b feat(frontend): migrate legacy localStorage autosave entries
Apps and flows used to autosave under un-scoped keys (`flow`/`flow-{path}`,
`app`/`app-{path}`, `rawapp`/`rawapp-{path}`) with a base64-encoded
state envelope. This adds a one-off migration that rewrites surviving
legacy entries under the workspace-scoped `userdraft/w/{ws}/{kind}/{path}`
keys with the new `{ value }` wrapper, transforms the payload where the
shape differs (drops the flow view-state envelope, defaults the new
raw-app `summary` field), and drops the source key.

The migration lives in its own file (`userDraftLegacyMigration.ts`)
so the new UserDraft service stays free of legacy decoders. Idempotent
via a `userdraft/legacy_migrated_v1` sentinel; runs from the logged-in
root layout once a workspace is known. Defensive shape checks avoid
clobbering co-resident apps that happen to use the same key prefixes.
2026-05-13 11:53:27 +02:00
Diego Imbert 9ca787a18b feat(frontend): staleness modal for flow, app, and raw-app editors
Migrates the flow, app, and raw_app editor routes to the same
`LocalDraftStaleModal` flow already used by scripts: compare the
recorded meta against the current `version` / `versions[last]` and
`draft_created_at`; on mismatch, surface the choice in a modal.

Adds `UserDraft.saveMeta` for routes that don't hold a live handle
(the app editor reads via `UserDraft.get` and the handle lives in
the child `AppEditor` component). It writes meta directly to
localStorage and tolerates the no-entry case.
2026-05-12 22:47:27 +02:00
Diego Imbert a32f257686 feat(frontend): staleness modal for the script editor's local autosave
Replace the script editor's toast-based "Discard / Show diff" pattern
with a dedicated modal that surfaces *why* the local autosave is out
of date: a new DB draft on the server, or a new deployed version.

Adds `checkStaleness` (UserDraftMeta vs current backend revs, draft-rev
priority) and a `setMeta({ force: true })` mode so the "Keep current
draft" acknowledgement persists even when it happens to be the
entry's first state mutation — under `saveInitialValue: false` an
ack-only setMeta would otherwise be skipped and the modal would
re-fire on next mount.

The modal lives at LocalDraftStaleModal.svelte; the script editor
wires it as a template for the remaining editors. Other editors
(flows, apps, raw_apps, resources, variables, triggers) still use
the previous toast pattern and will be migrated in follow-up
commits.
2026-05-12 22:42:50 +02:00
Diego ImbertandClaude Opus 4.7 946dfb12f4 feat(frontend): track remote rev metadata on UserDraft entries
Extends StoredDraft<V> with two optional rev fields used by the
forthcoming staleness modal:

- remoteRev — the deployed version's id/hash/timestamp at the moment
  the local draft was created. Compared against the latest deployed
  rev on reload.
- remoteDraftRev — the DB-draft created_at at the moment the local
  draft was created. Only meaningful for kinds that have a DB draft
  (script, flow, app, raw_app). Checked first so a teammate's draft
  push is detected before the "deployed version moved" case.

API additions on the handle returned by UserDraft.use():

- handle.meta — read the rev metadata currently stored.
- handle.setDraftAndMeta(value, meta) — atomic write of value + meta in
  a single state.val assignment. Editor routes use this on load so the
  baseline rev rides along with the value without consuming the
  saveInitialValue=false dedup slot twice.
- handle.setMeta(meta) — update just the rev metadata after the user
  picks "Keep current draft" in the staleness modal.
- handle.draft = X — unchanged surface; now preserves existing rev
  metadata across user edits.

Plus UserDraft.getMeta() and UserDraft.save() preserves any persisted
rev metadata when called without a live handle.

7 new tests cover the metadata surface; all 35 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:31:55 +02:00
Diego ImbertandClaude Opus 4.7 d844566421 feat(backend): expose freshness for UserDraft staleness check
Variable
- Add `edited_at TIMESTAMPTZ NOT NULL DEFAULT now()` + `edited_by VARCHAR(50)` to the `variable` table (parity with `resource`); set them on INSERT and on every UPDATE.
- Surface them on `ListableVariable` so `getVariable` / `listVariable` return them.

DB drafts (script, flow, app/raw_app)
- The `*WithDraft` endpoints now also return `draft.created_at` as `draft_created_at`. The draft value alone wasn't enough to tell whether a teammate (or another tab) had pushed a fresh draft while local autosave was in flight; the new field is the staleness signal.
- Wired in `get_script_by_path_w_draft` (`ScriptWDraft.draft_created_at`, including the `prefetch_cached` forwarding), `get_flow_by_path_w_draft` (`FlowWDraft.draft_created_at`), and `get_app_w_draft` (`AppWithLastVersionAndDraft.draft_created_at`). OpenAPI updated to match.

The frontend will read these in a follow-up to implement the local-draft staleness check; this commit only widens the API surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:23:45 +02:00
Diego ImbertandClaude Opus 4.7 1df77a076b Merge origin/main into change-24583b5f
Conflict resolution kept the UserDraft persistence on this branch and
incorporated the picker-navigation improvements from main:
- editPathFor / invalidate calls for workspace picker support
- loadFlowToken / loadAppToken / loadScriptToken stale-load guards
- onNavigate prop wiring

Dropped the legacy localStorage `flow-{path}` / `rawapp-{path}` /
`app-{path}` autosave paths that main still carried — they are
superseded by UserDraft (key `userdraft/w/{ws}/{kind}/{path}`).
Dropped main's `decodeState`/window.location.hash autosave for the
script editor for the same reason. The replaceStateFn prop that main
re-added to ScriptBuilder is also dropped (no longer exists on the
component).

Pre-existing typecheck errors in InstanceSetting.svelte and
useJobsLoader.svelte.ts come from main and reflect a stale
$lib/gen client — unrelated to the merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:45:37 +02:00
Diego ImbertandClaude Opus 4.7 f50971a8d5 refactor(frontend): wrap UserDraft localStorage payload as { value }
localStorage entries now look like {"value": <draft>} instead of just
<draft>. The wrapping is invisible at the API boundary — UserDraft.use,
.save, .get, .remove all still operate on the unwrapped draft value —
but it leaves room to add metadata (timestamps, originating user,
schema version, ...) later without breaking existing entries.

Internals:
- StoredDraft<V> = { value: V } is what we serialise to localStorage
  and what useLocalStorageValue's $state holds.
- wrap()/unwrap() helpers gate the boundary; the handle returned by
  use() unwraps on get and wraps on set.
- readPersisted() defensively drops entries whose payload isn't a
  { value: ... } object, so pre-migration drafts written by earlier
  commits on this branch are simply ignored (has() returns false,
  get() returns undefined) rather than confusingly surfacing as
  undefined-shaped drafts.

Test data switched from { value: X } (which collides confusingly with
the wrapper shape) to plain primitives / objects, plus a regression
test for the pre-migration ignore behaviour. 28 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:16 +02:00
Diego ImbertandClaude Opus 4.7 fd2e243795 refactor(frontend): rename schedule_* UserDraft kinds to trigger_*
The schedule_ prefix grouped all the trigger editors under what looked
like a "scheduler" namespace; trigger_ is what these actually are
(triggers — including the cron-style schedule). Mechanical rename
across UserDraftItemKind, every trigger editor's UserDraft.save/get/
remove calls, and the one test that asserted on the localStorage key.

Behaviour-only impact: existing localStorage keys under
userdraft/w/{ws}/schedule_{kind}/{path} from older builds will be
ignored on next open (no schema migration). Users will lose any
unsaved trigger drafts persisted before this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:25:40 +02:00
Diego ImbertandClaude Opus 4.7 03a5e98609 feat(frontend): wire ScheduleEditor to UserDraft
Same pattern, keyed on schedule_schedule. ScheduleEditor doesn't track
an originalConfig (its saveDisabled doesn't compare against a baseline)
so ordering is simpler — initialConfig snapshotted from backend, local
autosave overlaid after.

This completes UserDraft wiring across all 11 trigger editors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:26:46 +02:00
Diego ImbertandClaude Opus 4.7 9adc272437 feat(frontend): wire HTTP RouteEditor to UserDraft
Same pattern as the other triggers, keyed on schedule_http.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:24:40 +02:00
Diego ImbertandClaude Opus 4.7 48fafcc76b feat(frontend): wire EmailTrigger editor to UserDraft
Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:23:07 +02:00
Diego ImbertandClaude Opus 4.7 a76bb51515 feat(frontend): wire PostgresTrigger editor to UserDraft
Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:21:24 +02:00
Diego ImbertandClaude Opus 4.7 b5ba3f2abb feat(frontend): wire WebsocketTrigger editor to UserDraft
Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:19:37 +02:00
Diego ImbertandClaude Opus 4.7 dfc1651327 feat(frontend): wire AzureTrigger editor to UserDraft
Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:18:10 +02:00
Diego ImbertandClaude Opus 4.7 614180e53a feat(frontend): wire GcpTrigger editor to UserDraft
Same pattern as the other triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:16:35 +02:00
Diego ImbertandClaude Opus 4.7 d203a16f97 feat(frontend): wire MqttTrigger editor to UserDraft
Same pattern: persist getSaveCfg() on edits, overlay local autosave
in openEdit (with initialConfig/originalConfig snapshotted from
backend first), drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:14:45 +02:00
Diego ImbertandClaude Opus 4.7 2d495ef714 feat(frontend): wire NatsTrigger editor to UserDraft
Same pattern as the Kafka trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger (with initialConfig/originalConfig snapshotted from
backend first so hasChanged correctly reports the overlay as unsaved),
drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:13:22 +02:00
Diego ImbertandClaude Opus 4.7 807e6d88d3 feat(frontend): wire KafkaTrigger editor to UserDraft
Same pattern as the Sqs trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger, drop the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:12:19 +02:00
Diego ImbertandClaude Opus 4.7 2f0196af26 feat(frontend): wire SqsTrigger editor to UserDraft
Persist the trigger's getSaveCfg() output to
userdraft/w/{ws}/schedule_sqs/{path} on every edit, overlay any
existing local autosave on top of the backend value when openEdit
loads the trigger, and clear the entry on successful update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:08:30 +02:00
Diego ImbertandClaude Opus 4.7 e15b008175 fix(frontend): bake parent_hash into the initial script load
loadScript() assigned the backend value to scriptHandle.draft and then
deep-mutated parent_hash on the next line. Under
useLocalStorageValue's saveInitialValue=false contract only the very
first write is the baseline — the parent_hash mutation right after
counted as a second write and was persisted to localStorage, so
opening an existing script would silently write a draft entry even
though the user hadn't touched anything.

Combine `parent_hash` (and the topHash override) into a single
bakedBaseline so each branch of loadScript performs exactly one
assignment to scriptHandle.draft. Mirrored across the local-autosave
branch's discard callbacks too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:50:47 +02:00
Diego ImbertandClaude Opus 4.7 2dd5c6e19f fix(frontend): persist full multi-workspace bundle for resources/variables
ResourceEditor and VariableEditor can stage edits for several target
workspaces in a single drawer session (see deployTo / states[ws] map).
The previous UserDraft wiring only persisted states[$workspaceStore] —
the user's session workspace — so any edit made under a different
target workspace tab disappeared on refresh.

Persist the entire `states: Record<wsId, State>` bundle as the draft
value instead. On lazy-fetch we pick the local state for that ws if
present and divergent from the backend; on bootstrap for new
resources/variables we restore states for every workspace the user
had staged. The localStorage key still lives under the user's session
workspace via UserDraft, but its contents now cover all target
workspaces from that session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:32:28 +02:00
Diego ImbertandClaude Opus 4.7 655189e087 feat(frontend): saveInitialValue option on useLocalStorageValue
The first time a value flows into a UserDraft.use() handle — typically
the editor route loading the backend value via flowHandle.draft =
backendFlow — is the baseline, not a user edit. Persisting it on the
spot puts a copy of the backend into localStorage on every page open
and produces spurious "local autosave" toasts on next visit when the
serialization round-trips differently.

useLocalStorageValue now takes options.saveInitialValue (default true,
backward compatible). When false, the first time the serialised form
of the state changes — via the setter or via a deep mutation — the
lastSerialized cache is updated but localStorage is not touched. Every
write after that persists normally. UserDraft.use() passes false.

Tests updated to reflect the new contract (first write is the
baseline) and a regression test added for the second-write-persists
behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:44:01 +02:00
Diego Imbert daeab3f09f Only remove from localStorage 2026-05-12 14:59:17 +02:00
Diego Imbert bee09613b5 Revert "fix(frontend): don't UserDraft.remove flows while route is still mounted"
This reverts commit 079ebef72b.
2026-05-12 14:58:27 +02:00
Diego ImbertandClaude Opus 4.7 079ebef72b fix(frontend): don't UserDraft.remove flows while route is still mounted
The /flows/add and /flows/edit routes drive FlowBuilder from a flowStore
whose getter reads flowHandle.draft directly. Calling UserDraft.remove
synchronously before goto() therefore wiped the in-memory entry, made
flowStore.val collapse to emptyFlow(), and tripped
UnsavedConfirmationModal against the just-saved value — even though the
deploy/save-draft itself succeeded.

Drop those explicit removes in onSaveInitial, /add onDeploy, and
/edit onDeploy. The empty-path entry self-cleans on unmount via
onDestroy ref counting; for the non-empty edit path the next visit's
load-time diff will silently overwrite localStorage when the local
autosave matches the deployed value. Restore-draft/restore-deployed
keep their explicit remove because they navigate to the same route
(no modal) and loadFlow immediately rehydrates the handle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:13:51 +02:00
Diego Imbert bbe6767f24 editor external changes sync 2026-05-12 13:59:37 +02:00
Diego ImbertandClaude Opus 4.7 27d43da5b9 feat(frontend): wire variable editor to UserDraft
VariableEditor persists the current workspace's edit state via
UserDraft.save on every mutation, keyed on editPath ('' for new
variables → in-memory only). Backend fetches now overlay a matching
local autosave when one exists, and initNew() rehydrates from the
in-memory empty-path entry so opening a fresh "Add variable" drawer
keeps any unsaved work from the previous open. After a successful
save we drop the corresponding entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:41:24 +02:00
Diego ImbertandClaude Opus 4.7 a88f5c633e feat(frontend): wire resource editor to UserDraft
ResourceEditor registers a UserDraft.use<ResourceState> handle keyed
on the initialPath (empty for new resources, in-memory only). A
$effect deep-tracks the current workspace's edit state and forwards
mutations to the handle; on bootstrap and lazy backend-fetch the
local autosave wins over the backend value when they diverge. After
a successful save() we call UserDraft.remove so the local autosave
doesn't outlive the deploy. Cross-workspace deploys always start from
the live backend value rather than the local draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:39:59 +02:00
Diego ImbertandClaude Opus 4.7 b7ca257239 feat(frontend): wire raw app editor to UserDraft
/apps_raw/edit owns the canonical raw-app state (files, runnables,
data, summary) in four $state vars; a single $effect deep-tracks them
and forwards the bundle to a UserDraft.use<RawAppDraft> handle so each
mutation tick persists at userdraft/w/{ws}/raw_app/{path} (deduped by
useLocalStorageValue's serialized check). On load the route overlays
the local autosave on top of backend.draft/deployed and offers a
"Discard / Show diff" toast when they diverge; matching local entries
are silently dropped. Deploy, save-as-draft rename, restore-draft and
restore-deployed each call UserDraft.remove on the route path.

/apps_raw/add keeps the same shape (UserDraft.use with empty path)
so the draft is in-memory only and we drop it explicitly when the
initial save creates the real path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:37:54 +02:00
Diego ImbertandClaude Opus 4.7 1e77e46624 feat(frontend): wire app editor to UserDraft
AppEditor registers a UserDraft.use<App> handle for its current path
(empty path for /apps/add stays in-memory) and a single $effect
deep-tracks the internal stateApp and forwards every mutation to the
handle. useLocalStorageValue's lastSerialized check then dedupes the
actual localStorage writes per tick, so even fast drag/resize loops
only persist when the JSON output really changes.

/apps/edit overlays a local autosave from UserDraft.get on top of the
backend value when one exists, with the existing "Discard / Show diff"
toast wired to UserDraft.remove. Deploy, save-as-draft, restore-draft
and restore-deployed all call UserDraft.remove on the relevant path,
including the JSON editor save paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:34:30 +02:00
Diego ImbertandClaude Opus 4.7 46cfe43e60 feat(frontend): wire flow editor to UserDraft
flows/add and flows/edit drive the flow value through a StateStore
adapter backed by UserDraft.use, so every edit auto-persists at
userdraft/w/{ws}/flow/{path} without touching FlowBuilder's internal
.val convention. On returning visits the local autosave wins and a
toast offers a diff against the latest backend draft/deployed version;
on a fresh visit the backend value is written into the handle. Deploy,
save-as-draft rename, restore-draft and restore-deployed each call
UserDraft.remove on the route path so the local autosave doesn't
outlive the action.

Adds UserDraft.has() for "is there already a local draft?" detection
in the load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:25:12 +02:00
Diego ImbertandClaude Opus 4.7 da692257da feat(frontend): wire script editor to UserDraft
The script editor's top-level state now lives in UserDraft.use(), keyed
on the route's path (page.params.path on /scripts/edit, '' on /scripts/add).
Deep edits inside ScriptBuilder persist automatically; deploy and draft
restore now call UserDraft.remove to clear the local autosave alongside
the backend draft.

Replaces the URL-hash autosave that ScriptBuilder used to write via
replaceStateFn — that prop is now gone, the encodeScriptState debounce
is gone, and Triggers no longer takes a saveSessionDraft callback.
Viewing a specific historical hash (?hash=...) is kept draft-free by
passing '' as the path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:43:49 +02:00
Diego ImbertandClaude Opus 4.7 b56a189713 feat(frontend): add defaultValue + empty-path handling to UserDraft
UserDraft.use() accepts an opts.defaultValue used when no localStorage
entry exists yet. It is not persisted on first read — only an actual
mutation writes through.

Empty paths (new items) bypass localStorage entirely. The entry still
lives in the in-memory Map so multiple components on the same /add page
share state, but save/get/remove/use never read or write localStorage
with an empty path. Once the item is saved and the route navigates to
its new URL, a fresh use() on the non-empty path takes over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:37:00 +02:00
Diego ImbertandClaude Opus 4.7 6b7c1ea87a feat(frontend): persist deep mutations in useLocalStorageValue
Track the serialized value alongside the $state and add an $effect that
deep-reads it (via readFieldsRecursively). When a deep mutation produces
a serialization that differs from the last persisted blob, write it to
localStorage. The setter keeps writing synchronously so callers reading
localStorage right after assignment still see the new value; the effect
no-ops on those because lastSerialized was already updated by the setter.
Undefined values are persisted as a removal.

UserDraft no longer needs its own removeItem workarounds for undefined
values — useLocalStorageValue handles that uniformly now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:33:33 +02:00
Diego Imbert 6e29fdd5db nit schedule_ prefix 2026-05-11 12:28:30 +02:00
Diego Imbert a98a339ee6 tests 2026-05-11 12:26:21 +02:00
Diego ImbertandClaude Opus 4.7 fb221cb861 feat(frontend): add UserDraft service for per-workspace local drafts
Introduces UserDraft, a key-value store keyed by
`{workspace}/{itemKind}/{path}` and backed by localStorage. Supports
save/get/remove plus a reactive use() handle so multiple component
instances observing the same draft stay in sync via a shared $state
loaded through useLocalStorageValue. Designed to host drafts for
scripts, flows, apps, raw apps, resources, variables, and all trigger
kinds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:46:55 +02:00
Diego ImbertandClaude Opus 4.7 79ba0e0deb refactor(frontend): remove localStorage-backed autosave drafts
Strip the per-editor localStorage autosave for flows, apps and raw apps,
along with the associated restore toasts and diff actions, so we can
replace them with a unified UserDraft service in a follow-up. The
backend DraftService (DB-backed drafts) is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:44:58 +02:00
74 changed files with 6231 additions and 1409 deletions
@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Int4",
"Bool",
"Timestamptz",
"TextArray",
"Varchar"
]
},
"nullable": []
},
"hash": "295a88070e1762255cdd7680ba2e7bb2a2ffd66a7c432dd318e73e0f81ea9622"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3::text::json, $4)\n ON CONFLICT (workspace_id, path, typ)\n DO UPDATE SET value = EXCLUDED.value, created_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
{
"Custom": {
"name": "draft_type",
"kind": {
"Enum": [
"script",
"flow",
"app"
]
}
}
}
]
},
"nullable": []
},
"hash": "5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Text",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "5494652553c59b72ca5db4350a8ba3d8bbf1608519b08f2544c64ecfe130c537"
}
@@ -0,0 +1,3 @@
ALTER TABLE variable
DROP COLUMN IF EXISTS edited_by,
DROP COLUMN IF EXISTS edited_at;
@@ -0,0 +1,15 @@
-- Add `edited_at` and `edited_by` so the UI can detect when a variable has
-- been modified remotely while a local autosave was in flight (see the
-- UserDraft staleness check). Mirrors what `resource` already has.
--
-- Backfill: existing rows get `edited_at = now()` via the column's DEFAULT.
-- All pre-migration variables therefore appear to share a single edit
-- timestamp (the migration time). The staleness check only consumes
-- `edited_at` as an opaque rev string — it doesn't display or sort on it —
-- and only after the user edits a variable forward at least once. So the
-- collision is harmless: no UI flow looks at the pre-migration timestamp
-- before it gets overwritten by a real edit.
ALTER TABLE variable
ADD COLUMN edited_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
ADD COLUMN edited_by VARCHAR(50);
@@ -0,0 +1,5 @@
-- Symmetric to the up migration: Postgres's default `TIMESTAMPTZ -> TIMESTAMP`
-- cast strips the timezone by representing the instant in the session's
-- current timezone, mirroring how the original `now()` values were
-- truncated on insert.
ALTER TABLE draft ALTER COLUMN created_at TYPE TIMESTAMP;
@@ -0,0 +1,12 @@
-- `draft.created_at` was originally created as `TIMESTAMP` (no timezone). The
-- new `*WithDraft` API responses surface it as `chrono::DateTime<Utc>` for the
-- frontend's staleness check, which requires `TIMESTAMPTZ`.
--
-- We rely on Postgres's default `TIMESTAMP -> TIMESTAMPTZ` cast (no explicit
-- USING), which interprets each existing wall-clock value in the session's
-- current timezone. That's the exact semantics under which the original
-- `INSERT ... DEFAULT now()` values were truncated to TIMESTAMP — so the
-- conversion is a no-op on UTC servers (the common case) and correctly
-- recovers the original instant on non-UTC servers, instead of shifting all
-- pre-migration timestamps by the server's tz offset.
ALTER TABLE draft ALTER COLUMN created_at TYPE TIMESTAMPTZ;
+4
View File
@@ -1480,6 +1480,9 @@ pub struct FlowWDraft {
pub extra_perms: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1516,6 +1519,7 @@ async fn get_flow_by_path_w_draft(
flow.ws_error_handler_muted,
flow.dedicated_worker,
draft.value AS draft,
draft.created_at AS draft_created_at,
flow.tag,
flow.visible_to_runner_only,
flow.on_behalf_of_email,
+5 -1
View File
@@ -93,6 +93,9 @@ pub struct ScriptWDraft<SR> {
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
pub schema: Option<Schema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
@@ -170,6 +173,7 @@ impl ScriptWDraft<ScriptRunnableSettingsHandle> {
kind: self.kind,
tag: self.tag,
draft: self.draft,
draft_created_at: self.draft_created_at,
schema: self.schema,
draft_only: self.draft_only,
envs: self.envs,
@@ -1821,7 +1825,7 @@ async fn get_script_by_path_w_draft(
let mut tx = user_db.begin(&authed).await?;
let script_o = sqlx::query_as::<_, ScriptWDraft<ScriptRunnableSettingsHandle>>(
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script'
WHERE script.path = $1 AND script.workspace_id = $2
ORDER BY script.created_at DESC LIMIT 1",
+17
View File
@@ -9588,6 +9588,10 @@ paths:
properties:
draft:
$ref: "#/components/schemas/Flow"
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
/w/{workspace}/flows/exists/{path}:
get:
@@ -21726,6 +21730,10 @@ components:
properties:
draft:
$ref: "#/components/schemas/NewScript"
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
hash:
type: string
required:
@@ -22793,6 +22801,11 @@ components:
type: string
ws_specific:
type: boolean
edited_at:
type: string
format: date-time
edited_by:
type: string
required:
- workspace_id
- path
@@ -26789,6 +26802,10 @@ components:
draft_only:
type: boolean
draft: {}
draft_created_at:
type: string
format: date-time
description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check.
AppHistory:
type: object
+17 -13
View File
@@ -217,6 +217,9 @@ pub struct AppWithLastVersionAndDraft {
pub draft: Option<sqlx::types::Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
/// Timestamp at which the most recent DB draft was created.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
@@ -642,29 +645,30 @@ async fn get_app_w_draft(
let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>(
r#"
SELECT
app.id,
app.path,
app.summary,
app.versions,
app.policy,
SELECT
app.id,
app.path,
app.summary,
app.versions,
app.policy,
app.custom_path,
app.extra_perms,
app.extra_perms,
app_version.value,
app_version.created_at,
app_version.created_at,
app_version.created_by,
app.draft_only,
draft.value AS "draft",
draft.created_at AS "draft_created_at",
app_version.raw_app,
app.labels
FROM app
INNER JOIN app_version
INNER JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
LEFT JOIN draft
ON app.path = draft.path
AND draft.workspace_id = $2
LEFT JOIN draft
ON app.path = draft.path
AND draft.workspace_id = $2
AND draft.typ = 'app'
WHERE app.path = $1
WHERE app.path = $1
AND app.workspace_id = $2
"#,
)
+2 -1
View File
@@ -79,7 +79,8 @@ async fn create_draft(
"INSERT INTO draft
(workspace_id, path, value, typ)
VALUES ($1, $2, $3::text::json, $4)
ON CONFLICT (workspace_id, path, typ) DO UPDATE SET value = EXCLUDED.value",
ON CONFLICT (workspace_id, path, typ)
DO UPDATE SET value = EXCLUDED.value, created_at = now()",
&w_id,
draft.path,
//to preserve key orders
+4
View File
@@ -50,6 +50,10 @@ pub struct ListableVariable {
pub labels: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_specific: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edited_at: Option<chrono::DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edited_by: Option<String>,
}
#[derive(Serialize, Deserialize, sqlx::FromRow)]
+12 -5
View File
@@ -134,6 +134,8 @@ async fn list_variables(
"variable.expires_at",
"variable.labels",
"ws_specific.path IS NOT NULL as ws_specific",
"variable.edited_at",
"variable.edited_by",
])
.left()
.join("account")
@@ -216,6 +218,7 @@ async fn get_variable(
"SELECT variable.workspace_id, variable.path, variable.value, variable.is_secret,
variable.description, variable.extra_perms, variable.account, variable.is_oauth,
variable.expires_at, variable.labels,
variable.edited_at, variable.edited_by,
(now() > account.expires_at) as is_expired, account.refresh_error,
resource.path IS NOT NULL as is_linked,
account.refresh_token != '' as is_refreshed,
@@ -441,8 +444,8 @@ async fn create_variable(
sqlx::query!(
"INSERT INTO variable
(workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
(workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
&w_id,
variable.path,
value,
@@ -451,7 +454,8 @@ async fn create_variable(
variable.account,
variable.is_oauth.unwrap_or(false),
variable.expires_at,
variable.labels.as_deref() as Option<&[String]>
variable.labels.as_deref() as Option<&[String]>,
&authed.username
)
.execute(&mut *tx)
.await?;
@@ -1048,6 +1052,8 @@ async fn update_variable(
}
let npath = if has_sql_updates {
sqlb.set("edited_at", "now()");
sqlb.set_str("edited_by", &authed.username);
sqlb.returning("path");
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut *tx).await?;
@@ -1078,10 +1084,11 @@ async fn update_variable(
if let Some(nlabels) = &ns.labels {
sqlx::query!(
"UPDATE variable SET labels = $1 WHERE path = $2 AND workspace_id = $3",
"UPDATE variable SET labels = $1, edited_at = now(), edited_by = $4 WHERE path = $2 AND workspace_id = $3",
nlabels as &[String],
&npath,
&w_id
&w_id,
&authed.username
)
.execute(&mut *tx)
.await?;
+18 -18
View File
@@ -19,18 +19,18 @@
"open": "^10.0.0",
"svelte": "^5.45.2",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-csharp": "*",
"windmill-parser-wasm-go": "*",
"windmill-parser-wasm-java": "*",
"windmill-parser-wasm-nu": "*",
"windmill-parser-wasm-php": "*",
"windmill-parser-wasm-py": "^1.693.1",
"windmill-parser-wasm-py-imports": "^1.693.1",
"windmill-parser-wasm-regex": "*",
"windmill-parser-wasm-ruby": "*",
"windmill-parser-wasm-rust": "*",
"windmill-parser-wasm-ts": "^1.693.1",
"windmill-parser-wasm-yaml": "*",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.510.1",
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.693.1",
"windmill-parser-wasm-py-imports": "1.693.1",
"windmill-parser-wasm-regex": "1.692.0",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.647.1",
"windmill-parser-wasm-ts": "1.695.0",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-yaml-validator": "1.1.1",
"ws": "8.18.0",
"yaml": "^2.7.0"
@@ -1438,9 +1438,9 @@
"integrity": "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g=="
},
"node_modules/windmill-parser-wasm-regex": {
"version": "1.639.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz",
"integrity": "sha512-qvYM4sYxB6M0xrqwBljS2fWqOMk6rp++60TRltJnzZDzVaWQrKjTGwNMmfepGAIWy1OGVKp0SCVERhe2P+O6tQ=="
"version": "1.692.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.692.0.tgz",
"integrity": "sha512-BHGTxrinZJ9ef6hFxbKiBqBEr5uqgG/QySOgMA5r1LswO9n/8fyGswr8JcPT2kGaoeoweV6/RQ+RHVaOhosnKw=="
},
"node_modules/windmill-parser-wasm-ruby": {
"version": "1.526.1",
@@ -1453,9 +1453,9 @@
"integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="
},
"node_modules/windmill-parser-wasm-ts": {
"version": "1.693.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.693.1.tgz",
"integrity": "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ=="
"version": "1.695.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.695.0.tgz",
"integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw=="
},
"node_modules/windmill-parser-wasm-yaml": {
"version": "1.593.0",
+210
View File
@@ -0,0 +1,210 @@
# Global Mode AI Chat and UserDraft Requirements
## Purpose
This document enumerates the hard requirements that global mode AI chat places
on the frontend `UserDraft` service. It is intentionally limited to required
behavior and does not prescribe API names, data structures, or implementation
strategy.
Global mode AI chat must use the same local draft model as the user. A draft
modified through global mode is still a user draft. Requirements must therefore
avoid creating a separate "AI draft" ownership model.
The requirements are based on the current base branch state of `UserDraft`:
- drafts are scoped by workspace, item kind, and storage path;
- persisted drafts are stored in localStorage under `userdraft/w/...`;
- live editor handles may hold draft state that is not present in
localStorage;
- draft values are wrapped with freshness metadata used by editor staleness
checks;
- existing editor flows rely on `UserDraft` for autosave, restore, metadata,
removal, discard, and live-handle behavior;
- the service does not provide a public way to enumerate all draft entries;
- live editor state can include current loaded values that are useful context
but are not necessarily meaningful unsaved draft work.
## Requirements
### R1. Existing Editor Behavior Must Be Preserved
All existing editor behavior that depends on `UserDraft` must remain unchanged.
Opening an editor, hydrating current backend state, autosaving local edits,
restoring local edits, checking staleness, removing local drafts, and discarding
to a fallback value must keep the same observable behavior.
Global mode must not cause a clean editor baseline to become deployable draft
work.
### R2. Global Mode Must Share The User Draft Model
Drafts written or modified through global mode must be represented as normal
user drafts.
Global mode must be able to read, modify, deploy, and clear the same local draft
work that the user can act on through the editor, subject to the same workspace,
item-kind, freshness, and confirmation constraints.
The system must not require two parallel local draft concepts for the same
workspace item.
### R3. Meaningful Draft Work Must Be Distinguishable From Clean Context
Global mode must be able to distinguish meaningful local draft work from clean
frontend-visible context.
Clean editor baselines and current loaded backend values may be useful read
context, but they must not be listed, deployed, or cleared as draft work unless
they represent actual local draft changes.
This distinction must not depend on whether the last change was made by a human
or through global mode.
### R4. Global Mode Writes Must Be Visible Immediately
After global mode writes to a frontend local draft, the written value must be
visible immediately to:
- any live editor handle for the same workspace, item kind, and storage path;
- any localStorage-based lookup for that draft;
- any global mode read, list, deploy, or delete flow that runs after the write.
This must hold even when a live editor handle already exists before the write.
### R5. Freshness Metadata Must Not Be Lost
Global mode writes and any later metadata updates must preserve the freshness
metadata needed by existing editor staleness checks.
Freshness metadata must continue to reflect the backend state that the local
draft is based on.
### R6. Draft Enumeration Must Be Possible
Global mode must be able to enumerate frontend local drafts for a workspace and
a bounded set of item kinds.
Enumeration must include persisted-only drafts.
Enumeration must include live-only draft values when they represent meaningful
local draft work.
Enumeration must include draft values that exist both in localStorage and in a
live editor handle.
Enumeration must not return duplicate logical entries for the same workspace,
item kind, and storage path.
### R7. Enumeration Must Expose Storage Identity
For each enumerated draft, global mode must be able to identify the exact
workspace, item kind, and storage path used by `UserDraft`.
This is required because global mode operates on workspace item paths, while
`UserDraft` storage paths are not always equal to item paths.
New-item editor drafts with an empty storage path must remain representable.
### R8. Enumeration Must Expose Persistence State
For each enumerated draft, global mode must be able to determine whether the
value exists in localStorage, in a live editor handle, or in both.
Global mode must not need to infer persistence state by reading private
`UserDraft` internals.
### R9. Current Context And Draft Work Must Be Separatable
Global mode must be able to distinguish:
- current frontend-visible local state, which may include clean editor
baselines, loaded backend values, and local draft work;
- meaningful local draft work, which may be read, listed, deployed, or cleared
as draft state.
This distinction must be available without inspecting private `UserDraft`
internals.
### R10. Clearing A Draft Must Clear Persisted And Live State
When global mode clears a frontend local draft, the draft must be removed from
localStorage and from any live editor handle for the same workspace, item kind,
and storage path.
The cleared live state must not be re-persisted automatically as a side effect
of clearing.
Clearing a draft must not clear unrelated editor drafts or unrelated workspace
items.
### R11. Existing Discard Semantics Must Remain Available
Editor flows must retain the ability to discard local draft state while
restoring a provided fallback value in memory.
Global mode clearing requirements must not remove or weaken that editor discard
behavior.
### R12. Enumeration Must Be Robust To Editor Runtime Values
Draft enumeration must not fail because a live editor draft contains
runtime-only values that cannot be serialized into localStorage.
Enumeration may omit runtime-only details, but it must preserve the draft data
needed for global mode to identify, read, list, and clear the draft.
### R13. UserDraft Must Remain Domain-Agnostic
`UserDraft` must remain independent of global mode workspace item semantics.
It must not need to know about scripts, flows, apps, schedules, triggers,
resources, variables, deployment behavior, or chat tools.
Global mode-specific interpretation of item kinds and draft values must remain
outside `UserDraft`.
### R14. Backend Drafts Must Remain Separate From UserDraft
Backend draft-only scripts, flows, and apps are not frontend `UserDraft`
entries.
Global mode must not require `UserDraft` to represent backend DB drafts.
Global mode must be able to combine backend draft-aware reads with frontend
`UserDraft` state without treating one storage system as the other.
### R15. Cross-Workspace Isolation Must Be Preserved
Draft reads, writes, enumeration, and clearing must remain scoped to the target
workspace.
Global mode must not see, modify, deploy, or clear drafts from another
workspace.
### R16. Item-Kind Boundaries Must Be Preserved
Draft reads, writes, enumeration, and clearing must remain scoped to the target
item kind.
Global mode must not treat a draft of one item kind as a draft of another item
kind.
### R17. Regression Coverage Is Required
The UserDraft-only change must include tests covering:
- preservation of existing editor behavior;
- global mode writes sharing the same draft state as editor writes;
- clean editor baselines not being treated as draft work;
- persisted-only draft enumeration;
- live-only draft enumeration when it represents meaningful draft work;
- combined persisted and live draft enumeration without duplicates;
- storage path exposure when storage path differs from item path;
- global mode writes becoming visible to live handles and localStorage;
- clearing removing both persisted and live state;
- freshness metadata preservation;
- cross-workspace isolation;
- item-kind isolation;
- enumeration robustness when live draft values contain runtime-only data.
+19
View File
@@ -1916,6 +1916,25 @@
})
})
// External `code` prop changes should flow into the Monaco editor. The
// `untrack` block reads/writes Monaco without subscribing — only the
// prop read above is tracked — so the editor's own change handler
// (`updateCode`) re-running with the same value short-circuits and we
// don't loop.
$effect(() => {
const next = code ?? ''
const ed = editor
if (!ed) return
untrack(() => {
if (ed.getValue() === next) return
const model = ed.getModel()
if (!model) return
ed.pushUndoStop()
ed.executeEdits('external', [{ range: model.getFullModelRange(), text: next }])
ed.pushUndoStop()
})
})
let isTsWorkerInitialized = resource([() => lang, () => initialized], async () => {
if (lang !== 'typescript' || !initialized) return false
// Use the stable model URI (computed once at mount), not filePath which changes on rename
+2 -49
View File
@@ -14,7 +14,6 @@
import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores'
import {
cleanValueProperties,
encodeState,
generateRandomString,
orderedJsonStringify,
readFieldsRecursively,
@@ -291,12 +290,6 @@
loadingDraft = true
try {
const flow = cleanFlow(flowStore.val)
try {
localStorage.removeItem('flow')
localStorage.removeItem(`flow-${$pathStore}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
if (newFlow || savedFlow?.draft_only) {
if (savedFlow?.draft_only) {
await FlowService.deleteFlowByPath({
@@ -480,12 +473,6 @@
// return
if (newFlow) {
try {
localStorage.removeItem('flow')
localStorage.removeItem(`flow-${$pathStore}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: {
@@ -523,12 +510,6 @@
)
}
} else {
try {
localStorage.removeItem(`flow-${initialPath}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
@@ -578,32 +559,6 @@
}
}
let timeout: number | undefined = undefined
function saveSessionDraft() {
timeout && clearTimeout(timeout)
timeout = window.setTimeout(() => {
try {
localStorage.setItem(
initialPath && initialPath != '' ? `flow-${initialPath}` : 'flow',
encodeState({
flow: flowStore.val,
path: $pathStore,
selectedId: selectedIdStore,
draft_triggers: triggersState.getDraftTriggersSnapshot(),
selected_trigger: triggersState.getSelectedTriggerSnapshot(),
loadedFromHistory: {
flowJobInitial: stepHistoryLoader.flowJobInitial,
stepsState: stepHistoryLoader.stepStates
}
})
)
} catch (err) {
console.error(err)
}
}, 500)
}
const selectionManager = new SelectionManager()
const selectedIdStore = $derived(selectionManager.getSelectedId())
// Initialize with selected id if provided
@@ -694,8 +649,7 @@
{ type: 'default_email', path: '', isDraft: false },
...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? [])
],
untrack(() => selectedTriggerIndexFromUrl),
saveSessionDraft
untrack(() => selectedTriggerIndexFromUrl)
)
)
@@ -1022,7 +976,6 @@
$effect.pre(() => {
if (flowStore.val || selectedIdStore) {
readFieldsRecursively(flowStore.val)
untrack(() => saveSessionDraft())
}
})
// Sync `$pathStore` from `flowStore.val.path` (which `initFlow` populates
@@ -1063,7 +1016,7 @@
let stepHistoryLoader = new StepHistoryLoader(
untrack(() => loadedFromHistoryFromUrl)?.stepsState ?? {},
untrack(() => loadedFromHistoryFromUrl)?.flowJobInitial,
saveSessionDraft,
undefined,
untrack(() => noInitial)
)
setStepHistoryLoaderContext(stepHistoryLoader)
@@ -12,6 +12,9 @@
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 { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
interface Props {
canSave?: boolean
@@ -48,11 +51,82 @@
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let initialPath = path
let states: Record<string, ResourceState> = $state({})
// Per-workspace handles are driven by `useMany`. We track the workspace
// IDs (and their seeded defaults) in a parallel `$state` array; on every
// mutation `useMany` reconciles, acquiring entries for new workspaces and
// releasing them on component teardown. `states` indexes the resulting
// handles by workspace ID for ergonomic lookup downstream.
let workspaceSpecs = $state<Array<{ ws: string; defaultValue: ResourceState }>>([])
let initialStates: Record<string, ResourceState> = $state({})
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) => ({
itemKind: 'resource' as const,
path: initialPath ?? '',
workspace: s.ws,
defaultValue: s.defaultValue
}))
)
const states = $derived.by(() => {
const out: Record<string, UserDraftHandle<ResourceState>> = {}
for (let i = 0; i < workspaceSpecs.length; i++) {
const handle = handlesArray[i]
if (handle) out[workspaceSpecs[i].ws] = handle
}
return out
})
/** Register a workspace so `useMany` acquires (or reuses) its handle.
* `defaultValue` is what the handle reports when no autosave is persisted;
* an existing autosave always wins. The default itself never round-trips
* to localStorage — only the user's first real edit triggers a write. */
function ensureHandle(ws: string, defaultValue: ResourceState): void {
if (workspaceSpecs.some((s) => s.ws === ws)) return
workspaceSpecs.push({ ws, defaultValue })
}
let isValid = $state(true)
let jsonError = $state('')
@@ -85,7 +159,7 @@
})
let loadingSchema = $derived(resourceTypeResource.loading)
let current = $derived(selected ? states[selected] : undefined)
let current = $derived(selected ? states[selected]?.draft : undefined)
let resourceToEdit: Resource | undefined = $derived(
selected ? fetchedResources[selected] : undefined
)
@@ -107,7 +181,7 @@
)
const dirtyWorkspaces = $derived(
Object.keys(states).filter((ws) => !deepEqual(states[ws], initialStates[ws]))
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
const otherDirty = $derived(
@@ -121,7 +195,11 @@
const r = fetchedResources[ws]
return (
!r ||
canWrite(states[ws]?.path ?? initialPath, r.extra_perms ?? {}, perWsUser[ws] ?? $userStore)
canWrite(
states[ws]?.draft?.path ?? initialPath,
r.extra_perms ?? {},
perWsUser[ws] ?? $userStore
)
)
})
)
@@ -143,7 +221,7 @@
labels: undefined,
wsSpecific: false
}
states[effectiveWorkspace] = s
ensureHandle(effectiveWorkspace, s)
initialStates[effectiveWorkspace] = structuredClone(s)
existedInitially[effectiveWorkspace] = false
}
@@ -161,6 +239,7 @@
getUserExt(ws)
]).then(([r, user]) => {
fetchedResources[ws] = r
fetchedRev[ws] = r.edited_at
const s: ResourceState = {
path: r.path,
description: r.description ?? '',
@@ -168,7 +247,40 @@
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
}
states[ws] = s
// 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 }
)
}
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
}
})
}
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
existedInitially[ws] = true
perWsUser[ws] = user
@@ -180,6 +292,25 @@
})
})
// 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
@@ -215,7 +346,7 @@
const dirty = dirtyWorkspaces
try {
for (const ws of dirty) {
const s = states[ws]
const s = states[ws].draft!
const ini = initialStates[ws]
if (existedInitially[ws]) {
await ResourceService.updateResource({
@@ -246,6 +377,13 @@
}
})
}
// Saved on the backend — drop the local autosave for this
// workspace and refresh the dirty baseline. `s` is the
// UserDraft handle's draft, a Svelte $state proxy;
// `structuredClone` can't clone a proxy, so snapshot it to a
// plain object first.
initialStates[ws] = $state.snapshot(s) as ResourceState
UserDraft.remove('resource', initialPath ?? '', { workspace: ws })
}
sendUserToast(
dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource`
@@ -257,6 +395,13 @@
}
</script>
<LocalDraftStaleModal
open={staleModalOpen}
cause="version"
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
<div>
<div class="flex flex-col gap-6 py-2">
{#if otherDirty.length > 0}
@@ -4,7 +4,6 @@
const bubble = createBubbler()
import {
DraftService,
type NewScript,
ScriptService,
type NewScriptWithDraft,
type Script,
@@ -35,7 +34,6 @@
cleanValueProperties,
emptySchema,
emptyString,
encodeState,
generateRandomString,
orderedJsonStringify,
readFieldsRecursively,
@@ -110,7 +108,6 @@
savedScript = $bindable(undefined),
searchParams = new URLSearchParams(),
disableHistoryChange = false,
replaceStateFn = (url) => window.history.replaceState(null, '', url),
customUi = {},
savedPrimarySchedule = undefined,
functionExports = undefined,
@@ -258,15 +255,11 @@
// Add triggers context store
const triggersState = $state(
new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
],
undefined,
saveSessionDraft
)
new Triggers([
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
])
)
const captureOn = writable<boolean | undefined>(undefined)
@@ -330,28 +323,6 @@
let loadingSave = $state(false)
let loadingDraft = $state(false)
let timeout2: number | undefined = undefined
function encodeScriptState(script: NewScript) {
untrack(() => timeout2 && clearTimeout(timeout2))
timeout2 = setTimeout(() => {
replaceStateFn(
'#' +
encodeState({
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
})
)
}, 500)
}
let timeout: number | undefined = undefined
function saveSessionDraft() {
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
encodeScriptState(script)
}, 500)
}
if (script.content == '') {
if (template === 'wac_python') {
script.modules = {
@@ -514,11 +485,6 @@
loadingSave = true
try {
try {
localStorage.removeItem(script.path)
} catch (e) {
console.error('error interacting with local storage', e)
}
script.schema = script.schema ?? emptySchema()
try {
const result = await inferArgs(
@@ -654,11 +620,6 @@
loadingDraft = true
try {
try {
localStorage.removeItem(script.path)
} catch (e) {
console.error('error interacting with local storage', e)
}
script.schema = script.schema ?? emptySchema()
try {
const result = await inferArgs(
@@ -1037,7 +998,15 @@
})
$effect(() => {
readFieldsRecursively(script)
!disableHistoryChange && encodeScriptState(script)
})
// Mirror the draft triggers (held in a separate `triggersState` $state)
// back into `script.draft_triggers` so the UserDraft autosave — which
// deep-tracks `script` — picks them up. Pre-PR ScriptBuilder ran its own
// localStorage autosave that explicitly snapshotted triggersState; the
// switch to a unified UserDraft handle dropped that bridge.
$effect(() => {
readFieldsRecursively(triggersState.triggers)
script.draft_triggers = triggersState.getDraftTriggersSnapshot()
})
loadWorkerTags()
@@ -2061,11 +2061,6 @@
if (activeModuleTab === null) {
await inferSchema(editorCode)
}
try {
localStorage.setItem(path ?? 'last_save', activeModuleTab === null ? editorCode : code)
} catch (e) {
console.error('Could not save last_save to local storage', e)
}
dispatch('format')
}}
class="flex flex-1 h-full !overflow-visible"
+11 -3
View File
@@ -82,16 +82,24 @@
}
})
let color = classes[untrack(() => type)]
// Defensive: a miscall like `sendUserToast(msg, err)` passes a non-
// AlertType as `type`. Without a fallback the `classes[type]` lookup
// returns undefined and `color.descriptionClass` throws — and because
// the toast renders inside the root layout, that crashes the whole
// page instead of just dropping one toast. Coerce anything unknown to
// 'error' (a bad type almost always accompanies an error path).
const safeType: ToastType = untrack(() => (type in classes ? type : 'error'))
let color = classes[safeType]
let containerClass = {
success: 'toast-success',
error: 'toast-error',
info: 'toast-info',
warning: 'toast-warning'
}[untrack(() => type)]
}[safeType]
let Icon = $derived(icons[type])
let Icon = icons[safeType]
let showMore = $state(false)
const MAX_MSG_LEN = 160
@@ -15,6 +15,9 @@
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 { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte'
const dispatch = createEventDispatcher()
@@ -27,13 +30,79 @@
let editPath: string | undefined = $state(undefined)
let states: Record<string, VariableState> = $state({})
// Per-workspace handles are driven by `useMany`. We track the workspace
// IDs (and their seeded defaults) in a parallel `$state` array; on every
// mutation `useMany` reconciles, acquiring entries for new workspaces and
// releasing them on component teardown. `states` indexes the resulting
// handles by workspace ID for ergonomic lookup downstream.
let workspaceSpecs = $state<Array<{ ws: string; defaultValue: VariableState }>>([])
let initialStates: Record<string, VariableState> = $state({})
let existedInitially: Record<string, boolean> = $state({})
let extraPerms: Record<string, Record<string, boolean>> = $state({})
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) => ({
itemKind: 'variable' as const,
path: editPath ?? '',
workspace: s.ws,
defaultValue: s.defaultValue
}))
)
const states = $derived.by(() => {
const out: Record<string, UserDraftHandle<VariableState>> = {}
for (let i = 0; i < workspaceSpecs.length; i++) {
const handle = handlesArray[i]
if (handle) out[workspaceSpecs[i].ws] = handle
}
return out
})
/** Register a workspace so `useMany` acquires (or reuses) its handle.
* `defaultValue` is what the handle reports when no autosave is persisted;
* an existing autosave always wins. The default itself never round-trips
* to localStorage — only the user's first real edit triggers a write. */
function ensureHandle(ws: string, defaultValue: VariableState): void {
if (workspaceSpecs.some((s) => s.ws === ws)) return
workspaceSpecs.push({ ws, defaultValue })
}
let drawer: Drawer | undefined = $state()
let form: VariableForm | undefined = $state()
@@ -47,7 +116,7 @@
const MAX_VARIABLE_LENGTH = 10000
const edit = $derived(editPath !== undefined)
const initialPath = $derived(editPath ?? '')
const current = $derived(selected ? states[selected] : undefined)
const current = $derived(selected ? states[selected]?.draft : undefined)
const can_write = $derived.by(() => {
if (!selected || !edit) return true
const perms = extraPerms[selected]
@@ -55,7 +124,7 @@
return canWrite(editPath ?? '', perms, perWsUser[selected] ?? $userStore)
})
const dirtyWorkspaces = $derived(
Object.keys(states).filter((ws) => !deepEqual(states[ws], initialStates[ws]))
Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws]))
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
const otherDirty = $derived(
@@ -64,7 +133,10 @@
: dirtyWorkspaces
)
const dirtyValid = $derived(
dirtyWorkspaces.every((ws) => states[ws].variable.value.length <= MAX_VARIABLE_LENGTH)
dirtyWorkspaces.every((ws) => {
const v = states[ws].draft
return !!v && v.variable.value.length <= MAX_VARIABLE_LENGTH
})
)
const dirtyCanWrite = $derived(
dirtyWorkspaces.every((ws) => {
@@ -84,6 +156,7 @@
VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }),
getUserExt(ws)
]).then(([v, user]) => {
fetchedRev[ws] = v.edited_at
const s: VariableState = {
path: v.path,
variable: {
@@ -94,7 +167,29 @@
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
}
states[ws] = s
// 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 })
}
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.discard('variable', p, s, { workspace: ws })
}
})
}
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
existedInitially[ws] = true
extraPerms[ws] = v.extra_perms ?? {}
@@ -103,8 +198,26 @@
})
})
// 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() {
states = {}
// Clearing workspaceSpecs triggers useMany's reconcile to release
// every acquired entry. The $derived `states` then collapses to {}.
workspaceSpecs = []
initialStates = {}
existedInitially = {}
extraPerms = {}
@@ -122,7 +235,7 @@
labels: undefined,
wsSpecific: false
}
states[ws] = s
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
existedInitially[ws] = false
selected = ws
@@ -143,7 +256,7 @@
path: editPath,
decryptSecret: true
})
const s = states[selected]
const s = states[selected]?.draft
const ini = initialStates[selected]
if (s) s.variable.value = getV.value ?? ''
if (ini) ini.variable.value = getV.value ?? ''
@@ -154,7 +267,7 @@
const dirty = dirtyWorkspaces
try {
for (const ws of dirty) {
const s = states[ws]
const s = states[ws].draft!
const ini = initialStates[ws]
if (existedInitially[ws]) {
await VariableService.updateVariable({
@@ -186,6 +299,8 @@
}
})
}
// Saved on the backend — drop the local autosave for this workspace.
UserDraft.remove('variable', editPath ?? '', { workspace: ws })
}
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
dispatch('create')
@@ -196,6 +311,13 @@
}
</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'}
@@ -29,11 +29,12 @@
import { userStore, workspaceStore } from '$lib/stores'
import {
classNames,
encodeState,
getModifierKey,
readFieldsRecursively,
sendUserToast,
urlParamsToObject
} from '$lib/utils'
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
import AppPreview from './AppPreview.svelte'
import ComponentList from './componentsPanel/ComponentList.svelte'
import ContextPanel from './contextPanel/ContextPanel.svelte'
@@ -77,13 +78,55 @@
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
unsavedConfirmationModal,
onSavedNewAppPath
onSavedNewAppPath,
initialRevs
}: AppEditorProps = $props()
migrateApp(untrack(() => app))
const stateApp = $state(untrack(() => app))
const appDraftPath = newApp ? '' : (path ?? '')
const appDraftHandle = UserDraft.use<App>('app', appDraftPath)
// Prefer the persisted autosave over the prop when both exist (e.g.
// /apps/add reload: the route always initializes `app` to an empty
// template, but the user's last session is sitting in LS under the
// empty-path entry). The route is responsible for wiping the entry
// (`UserDraft.remove`) when it wants to force a fresh start —
// `?nodraft=true`, 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
$effect(() => {
readFieldsRecursively(stateApp)
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)
})
})
const selectedComponent = writable<string[] | undefined>(undefined)
// $: selectedComponent.subscribe((s) => {
@@ -166,7 +209,7 @@
runnableComponents: writable({}),
appPath: writablePath,
workspace: $workspaceStore ?? '',
onchange: () => saveFrontendDraft(),
onchange: undefined,
isEditor: true,
jobs: writable([]),
staticExporter: writable({}),
@@ -219,19 +262,6 @@
stylePanel: () => StylePanel
})
let timeout: number | undefined = undefined
function saveFrontendDraft() {
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
try {
localStorage.setItem(path != '' ? `app-${path}` : 'app', encodeState($appStore))
} catch (err) {
console.error('Error storing frontend draft in localStorage', err)
}
}, 500)
}
function hashchange(e: HashChangeEvent) {
context.hash = e.newURL.split('#')[1]
context = context
@@ -754,9 +784,6 @@
$effect(() => {
path && untrack(() => onPathChange())
})
$effect(() => {
$appStore && untrack(() => saveFrontendDraft())
})
$effect(() => {
context.mode = $mode == 'dnd' ? 'editor' : 'viewer'
})
@@ -5,6 +5,7 @@
import Toggle from '$lib/components/Toggle.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { redo, undo } from '$lib/history.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { isMac, type Item, userPathPrefix } from '$lib/utils'
import { random_adj } from '$lib/components/random_positive_adjetive'
@@ -213,11 +214,7 @@
}
closeSaveDrawer()
sendUserToast('App deployed successfully')
try {
localStorage.removeItem(`app-${path}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
UserDraft.remove('app', path)
onSavedNewAppPath?.(path)
} catch (e) {
sendUserToast('Error creating app', e)
@@ -317,12 +314,8 @@
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('app', $appPath)
if ($appPath !== npath) {
try {
localStorage.removeItem(`app-${appPath}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
onSavedNewAppPath?.(npath)
}
}
@@ -394,6 +387,10 @@
}
draftDrawerOpen = false
// The initial draft was promoted to a real path on the backend —
// drop the autosave keyed on the prior (possibly empty) path so
// a future "+ App" click opens on a clean slate.
UserDraft.remove('app', $appPath)
onSavedNewAppPath?.(newEditedPath)
} catch (e) {
sendUserToast('Error saving initial draft', e)
@@ -484,11 +481,7 @@
}
sendUserToast('Draft saved')
try {
localStorage.removeItem(`app-${path}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
UserDraft.remove('app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
onSavedNewAppPath?.(newEditedPath || path)
@@ -5,6 +5,7 @@
import JsonEditor from '../../JsonEditor.svelte'
import { AppService, DraftService } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { sendUserToast } from '$lib/toast'
import { userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
@@ -45,11 +46,7 @@
requestBody: { ...app, value: JSON.parse(code) }
})
dispatch('change')
try {
localStorage.removeItem(`app-${path}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
UserDraft.remove('app', path)
sendUserToast('App deployed')
}
@@ -63,11 +60,7 @@
}
})
dispatch('change')
try {
localStorage.removeItem(`app-${path}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
UserDraft.remove('app', path)
sendUserToast('Draft saved')
}
</script>
+10
View File
@@ -164,6 +164,16 @@ export interface AppEditorProps {
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
onSavedNewAppPath?: (path: string) => 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
}
export type App = {
@@ -0,0 +1,118 @@
<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'
type Cause = 'draft' | 'version'
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'
: '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."
: "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."
)
</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">Load latest version</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}
@@ -26,6 +26,14 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({
vi.mock('$lib/components/vscode', () => ({}))
vi.mock('svelte', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
onDestroy: vi.fn()
}
})
vi.mock('$lib/gen', async () => {
const actual = await vi.importActual<any>('$lib/gen')
@@ -42,17 +50,81 @@ vi.mock('$lib/gen', async () => {
return {
...actual,
ScriptService: wrapService(actual.ScriptService, {
existsScriptByPath: vi.fn(async () => false),
getScriptByPath: vi.fn(),
getScriptByPathWithDraft: vi.fn(),
listScripts: vi.fn(async () => [])
}),
FlowService: wrapService(actual.FlowService, {
existsFlowByPath: vi.fn(async () => false)
existsFlowByPath: vi.fn(async () => false),
listFlows: vi.fn(async () => [])
}),
ScheduleService: wrapService(actual.ScheduleService, {
existsSchedule: vi.fn(async () => false),
listSchedules: vi.fn(async () => [])
}),
HttpTriggerService: wrapService(actual.HttpTriggerService, {
existsHttpTrigger: vi.fn(async () => false),
listHttpTriggers: vi.fn(async () => [])
}),
WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, {
existsWebsocketTrigger: vi.fn(async () => false),
listWebsocketTriggers: vi.fn(async () => [])
}),
KafkaTriggerService: wrapService(actual.KafkaTriggerService, {
existsKafkaTrigger: vi.fn(async () => false),
listKafkaTriggers: vi.fn(async () => [])
}),
NatsTriggerService: wrapService(actual.NatsTriggerService, {
existsNatsTrigger: vi.fn(async () => false),
listNatsTriggers: vi.fn(async () => [])
}),
PostgresTriggerService: wrapService(actual.PostgresTriggerService, {
existsPostgresTrigger: vi.fn(async () => false),
listPostgresTriggers: vi.fn(async () => [])
}),
MqttTriggerService: wrapService(actual.MqttTriggerService, {
existsMqttTrigger: vi.fn(async () => false),
listMqttTriggers: vi.fn(async () => [])
}),
SqsTriggerService: wrapService(actual.SqsTriggerService, {
existsSqsTrigger: vi.fn(async () => false),
listSqsTriggers: vi.fn(async () => [])
}),
GcpTriggerService: wrapService(actual.GcpTriggerService, {
existsGcpTrigger: vi.fn(async () => false),
listGcpTriggers: vi.fn(async () => [])
}),
AzureTriggerService: wrapService(actual.AzureTriggerService, {
existsAzureTrigger: vi.fn(async () => false),
listAzureTriggers: vi.fn(async () => [])
}),
AppService: wrapService(actual.AppService, {
existsApp: vi.fn(async () => false)
}),
ResourceService: wrapService(actual.ResourceService, {
existsResource: vi.fn(async () => false),
listResource: vi.fn(async () => []),
queryResourceTypes: vi.fn(async () => [])
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: vi.fn(async () => false)
existsVariable: vi.fn(async () => false),
getVariable: vi.fn(async ({ path }: { path: string }) => ({
path,
value: 'super-secret-token',
is_secret: true,
description: 'API key'
})),
listVariable: vi.fn(async () => [])
})
}
})
import { globalTools, prepareGlobalUserMessage } from './core'
import { globalDraftStore } from './draftStore.svelte'
import { deleteGlobalDraft, listGlobalDrafts } from './userDraftAdapter'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { ScriptService } from '$lib/gen'
import type { Tool, ToolCallbacks } from '../shared'
const WORKSPACE = 'global-core-test'
@@ -86,18 +158,556 @@ async function callGlobalTool(
describe('global AI tools', () => {
beforeEach(() => {
globalDraftStore.clearDrafts(WORKSPACE)
__resetUserDraftForTesting()
localStorage.clear()
vi.clearAllMocks()
})
it('redacts variable draft values when reading workspace items', async () => {
it('writes resource and variable drafts into the shared UserDraft store', async () => {
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
value: 'super-secret-token',
value: 'secret',
is_secret: true,
description: 'API key'
})
await callGlobalTool('write_resource', {
path: 'f/resources/api',
resource_type: 'http',
description: 'API resource',
value: {
token: '$var:f/secrets/api_key'
}
})
expect(
UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/secrets/api_key',
value: 'secret',
is_secret: true,
description: 'API key'
})
expect(
UserDraft.get<any>('resource', 'f/resources/api', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/resources/api',
resource_type: 'http',
description: 'API resource',
value: {
token: '$var:f/secrets/api_key'
}
})
})
it('writes script drafts into the shared UserDraft store', async () => {
const content = 'export async function main() {\n\treturn "hello"\n}'
await callGlobalTool('write_script', {
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
})
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
{
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
}
)
const raw = await callGlobalTool('read_workspace_item', {
type: 'script',
path: 'f/scripts/hello'
})
expect(JSON.parse(raw)).toMatchObject({
type: 'script',
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
value: content,
isDraft: true
})
})
it('reads backend draft-only scripts through the draft-aware endpoint', async () => {
const content = 'export async function main() {\n\treturn "db draft"\n}'
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
hash: 'draft-hash',
path: 'f/scripts/backend-draft',
summary: 'Backend draft',
description: '',
content,
language: 'bun',
draft_only: true
} as any)
const raw = await callGlobalTool('read_workspace_item', {
type: 'script',
path: 'f/scripts/backend-draft'
})
expect(JSON.parse(raw)).toMatchObject({
type: 'script',
path: 'f/scripts/backend-draft',
summary: 'Backend draft',
language: 'bun',
value: content,
isDraft: true
})
expect(ScriptService.getScriptByPathWithDraft).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/backend-draft'
})
expect(ScriptService.getScriptByPath).not.toHaveBeenCalled()
})
it('reads a live UserDraft editor baseline as current context without marking it draft', async () => {
const content = 'export async function main() {\n\treturn "open editor"\n}'
const handle = UserDraft.use<any>('script', 'f/scripts/open-editor', { workspace: WORKSPACE })
handle.setDraftAndMeta(
{
path: 'f/scripts/open-editor',
summary: 'Open editor baseline',
language: 'bun',
content
},
{ remoteRev: 'v1' }
)
const raw = await callGlobalTool('read_workspace_item', {
type: 'script',
path: 'f/scripts/open-editor'
})
expect(JSON.parse(raw)).toMatchObject({
type: 'script',
path: 'f/scripts/open-editor',
summary: 'Open editor baseline',
language: 'bun',
value: content,
isDraft: false
})
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/open-editor`)).toBeNull()
})
it('does not treat a clean live editor baseline as a deployable global draft', async () => {
const content = 'export async function main() {\n\treturn "baseline"\n}'
const handle = UserDraft.use<any>('script', 'f/scripts/baseline', { workspace: WORKSPACE })
handle.setDraftAndMeta(
{
path: 'f/scripts/baseline',
summary: 'Clean baseline',
language: 'bun',
content
},
{ remoteRev: 'v1' }
)
expect(listGlobalDrafts(WORKSPACE)).toEqual([])
await expect(
callGlobalTool('deploy_workspace_item', {
type: 'script',
path: 'f/scripts/baseline'
})
).rejects.toThrow('No AI draft found for script "f/scripts/baseline".')
})
it('does not treat a persisted editor baseline as a deployable global draft', async () => {
const key = `userdraft/w/${WORKSPACE}/script/f/scripts/persisted-baseline`
localStorage.setItem(
key,
JSON.stringify({
value: {
path: 'f/scripts/persisted-baseline',
summary: 'Persisted baseline',
language: 'bun',
content: 'export async function main() {\n\treturn "baseline"\n}',
parent_hash: 'h1'
},
remoteRev: 'h1'
})
)
expect(listGlobalDrafts(WORKSPACE)).toEqual([])
await expect(
callGlobalTool('deploy_workspace_item', {
type: 'script',
path: 'f/scripts/persisted-baseline'
})
).rejects.toThrow('No AI draft found for script "f/scripts/persisted-baseline".')
})
it('persists AI writes into a live editor handle so they remain deployable', async () => {
const handle = UserDraft.use<any>('script', 'f/scripts/live-ai', { workspace: WORKSPACE })
handle.setDraftAndMeta(
{
path: 'f/scripts/live-ai',
summary: 'Clean baseline',
language: 'bun',
content: 'export async function main() {\n\treturn 1\n}'
},
{ remoteRev: 'v1' }
)
await callGlobalTool('write_script', {
path: 'f/scripts/live-ai',
summary: 'AI edit',
language: 'bun',
content: 'export async function main() {\n\treturn 2\n}'
})
expect(handle.draft?.content).toContain('return 2')
expect(listGlobalDrafts(WORKSPACE)).toEqual([
expect.objectContaining({
type: 'script',
path: 'f/scripts/live-ai',
summary: 'AI edit',
isDraft: true
})
])
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/live-ai`)).not.toBeNull()
})
it('preserves NewScript metadata from existing scripts without copying backend read fields', async () => {
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true)
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
workspace_id: WORKSPACE,
hash: 'old-hash',
path: 'f/scripts/existing',
parent_hashes: ['older-hash'],
summary: 'Existing script',
description: 'Existing description',
content: 'export async function main() { return 1 }',
created_by: 'admin',
created_at: '2026-01-01T00:00:00Z',
archived: false,
deleted: false,
extra_perms: {},
is_template: true,
language: 'bun',
kind: 'script',
starred: true,
has_draft: true,
schema: {
type: 'object',
properties: {
name: { type: 'string' }
}
},
concurrent_limit: 3,
cache_ttl: 30,
cache_ignore_s3_path: true,
has_preprocessor: true,
assets: [{ path: 'asset.txt', kind: 's3object' }],
labels: ['important']
} as any)
await callGlobalTool('write_script', {
path: 'f/scripts/existing',
summary: 'AI update',
language: 'python3',
content: 'def main():\n return 2'
})
const draft = UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
expect(draft).toMatchObject({
path: 'f/scripts/existing',
parent_hash: 'old-hash',
summary: 'AI update',
description: 'Existing description',
content: 'def main():\n return 2',
language: 'python3',
is_template: true,
schema: {
properties: {
name: { type: 'string' }
}
},
concurrent_limit: 3,
cache_ttl: 30,
cache_ignore_s3_path: true,
has_preprocessor: true,
assets: [{ path: 'asset.txt', kind: 's3object' }],
labels: ['important']
})
expect(draft).not.toHaveProperty('hash')
expect(draft).not.toHaveProperty('workspace_id')
expect(draft).not.toHaveProperty('created_by')
expect(draft).not.toHaveProperty('starred')
expect(draft).not.toHaveProperty('extra_perms')
})
it('deleteGlobalDraft clears both persisted storage and any live handle state', async () => {
const handle = UserDraft.use<any>('script', 'f/scripts/delete-live', { workspace: WORKSPACE })
await callGlobalTool('write_script', {
path: 'f/scripts/delete-live',
summary: 'Delete me',
language: 'bun',
content: 'export async function main() {\n\treturn "delete"\n}'
})
expect(handle.draft).toBeDefined()
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/delete-live`)).not.toBeNull()
deleteGlobalDraft(WORKSPACE, 'script', 'f/scripts/delete-live')
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/delete-live`)).toBeNull()
})
it('lists and edits a new script draft stored under the empty add-editor key by its assigned path', async () => {
const content = 'export async function main() {\n\treturn 1\n}'
const handle = UserDraft.use<any>('script', '', { workspace: WORKSPACE })
handle.setDraftAndMeta(
{
path: 'u/admin/assigned_script',
summary: '',
language: 'bun',
content
},
{}
)
const raw = await callGlobalTool('list_workspace_items', {
types: ['script']
})
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'script',
path: 'u/admin/assigned_script',
isDraft: false
})
])
UserDraft.get<any>('script', '', { workspace: WORKSPACE }).uncloneable = () =>
'runtime-only editor field'
await callGlobalTool('edit_script', {
path: 'u/admin/assigned_script',
old_string: 'return 1',
new_string: 'return 2',
replace_all: false
})
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })?.content).toContain(
'return 2'
)
expect(
UserDraft.get<any>('script', 'u/admin/assigned_script', { workspace: WORKSPACE })
).toBeUndefined()
})
it('edits a new script draft stored under the empty add-editor key before listing it', async () => {
const content = 'export async function main() {\n\treturn 1\n}'
const handle = UserDraft.use<any>('script', '', { workspace: WORKSPACE })
handle.setDraftAndMeta(
{
path: 'u/admin/direct_edit_script',
summary: '',
language: 'bun',
content
},
{}
)
UserDraft.get<any>('script', '', { workspace: WORKSPACE }).uncloneable = () =>
'runtime-only editor field'
await callGlobalTool('edit_script', {
path: 'u/admin/direct_edit_script',
old_string: 'return 1',
new_string: 'return 2',
replace_all: false
})
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })?.content).toContain(
'return 2'
)
expect(
UserDraft.get<any>('script', 'u/admin/direct_edit_script', { workspace: WORKSPACE })
).toBeUndefined()
})
it('overlays shared UserDraft entries in list_workspace_items', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/listed',
summary: 'Listed script',
language: 'bun',
content: 'export async function main() { return 1 }'
})
const raw = await callGlobalTool('list_workspace_items', {
types: ['script']
})
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'script',
path: 'f/scripts/listed',
summary: 'Listed script',
isDraft: true
})
])
})
it('keeps local UserDraft overlays marked as drafts after path_prefix filtering', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/listed',
summary: 'Listed script',
language: 'bun',
content: 'export async function main() { return 1 }'
})
const raw = await callGlobalTool('list_workspace_items', {
types: ['script'],
path_prefix: 'f/scripts'
})
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'script',
path: 'f/scripts/listed',
isDraft: true
})
])
})
it('applies path_prefix to local UserDraft overlays in list_workspace_items', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/listed',
summary: 'Listed script',
language: 'bun',
content: 'export async function main() { return 1 }'
})
const raw = await callGlobalTool('list_workspace_items', {
types: ['script'],
path_prefix: 'f/other'
})
expect(JSON.parse(raw)).toEqual([])
})
it('does not inspect unrelated partial flow UserDraft entries when listing scripts', async () => {
UserDraft.save<any>('flow', 'f/flows/partial', {}, { workspace: WORKSPACE })
const raw = await callGlobalTool('list_workspace_items', {
types: ['script']
})
expect(JSON.parse(raw)).toEqual([])
})
it('lists legacy top-level flow UserDraft values without crashing', async () => {
UserDraft.save<any>(
'flow',
'f/flows/legacy',
{
modules: [
{
id: 'start',
value: { type: 'identity' }
}
]
},
{ workspace: WORKSPACE }
)
const raw = await callGlobalTool('list_workspace_items', {
types: ['flow']
})
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'flow',
path: 'f/flows/legacy',
isDraft: false
})
])
})
it('writes schedule drafts into the shared UserDraft store', async () => {
await callGlobalTool('write_schedule', {
path: 'f/schedules/daily',
summary: 'Daily schedule',
schedule: '0 0 12 * * *',
timezone: 'UTC',
script_path: 'f/scripts/hello',
is_flow: false,
args: { name: 'Ada' },
enabled: true
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/daily', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/schedules/daily',
summary: 'Daily schedule',
script_path: 'f/scripts/hello',
is_flow: false
})
const raw = await callGlobalTool('read_workspace_item', {
type: 'schedule',
path: 'f/schedules/daily'
})
expect(JSON.parse(raw)).toMatchObject({
type: 'schedule',
path: 'f/schedules/daily',
summary: 'Daily schedule',
value: expect.objectContaining({
schedule: '0 0 12 * * *',
timezone: 'UTC'
}),
isDraft: true
})
})
it('writes trigger drafts into the shared trigger UserDraft kind', async () => {
await callGlobalTool('write_trigger', {
kind: 'http',
config: {
path: 'f/triggers/hook',
summary: 'Hook trigger',
script_path: 'f/scripts/hello',
route_path: 'api/hook',
is_flow: false,
http_method: 'post',
authentication_method: 'none',
is_static_website: false
}
})
expect(
UserDraft.get<any>('trigger_http', 'f/triggers/hook', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/triggers/hook',
summary: 'Hook trigger',
route_path: 'api/hook',
is_flow: false
})
const raw = await callGlobalTool('list_workspace_items', {
types: ['trigger']
})
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'trigger',
triggerKind: 'http',
path: 'f/triggers/hook',
summary: 'Hook trigger',
isDraft: true
})
])
})
it('redacts existing variable values when reading workspace items', async () => {
const raw = await callGlobalTool('read_workspace_item', {
type: 'variable',
path: 'f/secrets/api_key'
@@ -109,7 +719,7 @@ describe('global AI tools', () => {
type: 'variable',
path: 'f/secrets/api_key',
summary: 'API key',
isDraft: true
isDraft: false
})
})
@@ -130,6 +740,20 @@ describe('global AI tools', () => {
])
})
expect(
UserDraft.get<any>('flow', 'f/flows/empty-module', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/flows/empty-module',
summary: 'Flow with empty module',
value: {
modules: [
expect.objectContaining({
id: 'empty_step'
})
]
}
})
const code = 'export async function main() {\n\treturn 42\n}'
await expect(
@@ -148,6 +772,28 @@ describe('global AI tools', () => {
).resolves.toBe(code)
})
it('stores raw app drafts in the shared raw_app UserDraft kind', async () => {
await callGlobalTool('init_app', {
path: 'f/apps/demo',
summary: 'Demo app',
framework: 'react19'
})
await callGlobalTool('write_app_file', {
path: 'f/apps/demo',
file_path: '/App.tsx',
content: 'export default function App() { return <main>Demo</main> }'
})
const draft = UserDraft.get<any>('raw_app', 'f/apps/demo', { workspace: WORKSPACE })
expect(draft).toMatchObject({
summary: 'Demo app',
files: expect.objectContaining({
'/App.tsx': 'export default function App() { return <main>Demo</main> }'
})
})
})
it('writes flows with flow-mode arguments and reads compact flow value', async () => {
const writeResult = JSON.parse(
await callGlobalTool('write_flow', {
@@ -181,6 +827,7 @@ describe('global AI tools', () => {
})
const item = JSON.parse(raw)
expect(item.isDraft).toBe(true)
expect(item.value).toMatchObject({
modules: [
{
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Flow, NewScript, Script } from '$lib/gen/types.gen'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import type { WorkspaceItem } from './draftStore.svelte'
import type { WorkspaceItem } from './workspaceItems'
describe('global AI deploy request builders', () => {
it('preserves existing script metadata while replacing draft-controlled fields', () => {
@@ -1,5 +1,5 @@
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte'
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
@@ -1,192 +0,0 @@
import type {
AzureTriggerData,
CreateResource,
CreateVariable,
FlowValue,
GcpTriggerData,
NewHttpTrigger,
NewKafkaTrigger,
NewMqttTrigger,
NewNatsTrigger,
NewPostgresTrigger,
NewSchedule,
NewSqsTrigger,
NewWebsocketTrigger,
Policy,
ScriptLang
} from '$lib/gen/types.gen'
/**
* Flow draft value. Mirrors what the backend's create/update flow API expects
* the OpenFlow value, plus the inputs schema and (optional) groups.
*
* Schema and groups are split out from FlowValue intentionally so that
* deploy_workspace_item can preserve them through the draft workspace
* round-trip; an earlier version dropped them on every deploy.
*/
export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
}
export const TRIGGER_KINDS = [
'http',
'websocket',
'kafka',
'nats',
'postgres',
'mqtt',
'sqs',
'gcp',
'azure'
] as const
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
export type TriggerRequestBody =
| NewHttpTrigger
| NewWebsocketTrigger
| NewKafkaTrigger
| NewNatsTrigger
| NewPostgresTrigger
| NewMqttTrigger
| NewSqsTrigger
| GcpTriggerData
| AzureTriggerData
export type WorkspaceItemType =
| 'script'
| 'flow'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
| 'app'
export type AppDraftValue = {
summary?: string
files: Record<string, string>
runnables: Record<string, any>
data?: any
policy?: Policy
custom_path?: string
}
export type WorkspaceItem = {
type: WorkspaceItemType
path: string
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
value?:
| string
| FlowDraftValue
| NewSchedule
| TriggerRequestBody
| CreateResource
| CreateVariable
| AppDraftValue
isDraft: boolean
}
export function getWorkspaceItemKey(
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
if (type === 'trigger') {
return `trigger:${triggerKind ?? ''}:${path}`
}
return `${type}:${path}`
}
function clone<T>(value: T): T {
return structuredClone($state.snapshot(value)) as T
}
class GlobalDraftStore {
private drafts = $state<Record<string, Record<string, WorkspaceItem>>>({})
private getWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
return this.drafts[workspace] ?? {}
}
private ensureWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
if (!this.drafts[workspace]) {
this.drafts[workspace] = {}
}
return this.drafts[workspace]
}
listDrafts(workspace: string): WorkspaceItem[] {
return Object.values(this.getWorkspaceDrafts(workspace)).map(clone)
}
getDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)]
return draft ? clone(draft) : undefined
}
setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem {
const stored: WorkspaceItem = { ...clone(item), isDraft: true }
this.ensureWorkspaceDrafts(workspace)[
getWorkspaceItemKey(item.type, item.path, item.triggerKind)
] = stored
return clone(stored)
}
deleteDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): void {
const drafts = this.drafts[workspace]
if (!drafts) return
delete drafts[getWorkspaceItemKey(type, path, triggerKind)]
if (Object.keys(drafts).length === 0) {
delete this.drafts[workspace]
}
}
clearDrafts(workspace: string): void {
delete this.drafts[workspace]
}
getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'script', path)
}
getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'flow', path)
}
getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'schedule', path)
}
getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'trigger', path, kind)
}
getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'resource', path)
}
getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'variable', path)
}
getAppDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'app', path)
}
}
export const globalDraftStore = new GlobalDraftStore()
@@ -1,56 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { globalDraftStore } from './draftStore.svelte'
const WORKSPACE_A = 'draft-store-test-a'
const WORKSPACE_B = 'draft-store-test-b'
function clearTestDrafts() {
globalDraftStore.clearDrafts(WORKSPACE_A)
globalDraftStore.clearDrafts(WORKSPACE_B)
}
describe('globalDraftStore', () => {
beforeEach(clearTestDrafts)
it('lists and reads drafts only from the requested workspace', () => {
globalDraftStore.setDraft(WORKSPACE_A, {
type: 'script',
path: 'f/shared/path',
language: 'bun',
value: 'export async function main() {}',
isDraft: true
})
expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe(
'export async function main() {}'
)
expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined()
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1)
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
})
it('deletes and clears drafts only from the requested workspace', () => {
globalDraftStore.setDraft(WORKSPACE_A, {
type: 'flow',
path: 'f/shared/path',
value: { value: { modules: [] }, schema: null, groups: null },
isDraft: true
})
globalDraftStore.setDraft(WORKSPACE_B, {
type: 'flow',
path: 'f/shared/path',
value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null },
isDraft: true
})
globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path')
expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined()
expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined()
globalDraftStore.clearDrafts(WORKSPACE_B)
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0)
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
})
})
@@ -0,0 +1,513 @@
import type {
CreateResource,
CreateVariable,
FlowValue,
NewSchedule,
NewScript
} from '$lib/gen/types.gen'
import {
UserDraft,
type UserDraftEntrySource,
type UserDraftItemKind,
type UserDraftListEntry
} from '$lib/userDraft.svelte'
import {
getWorkspaceItemKey,
type AppDraftValue,
type TriggerRequestBody,
type TriggerKind,
type WorkspaceItem,
type WorkspaceItemType
} from './workspaceItems'
type SharedWorkspaceItemType =
| 'script'
| 'flow'
| 'app'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = {
http: 'trigger_http',
websocket: 'trigger_websocket',
kafka: 'trigger_kafka',
nats: 'trigger_nats',
postgres: 'trigger_postgres',
mqtt: 'trigger_mqtt',
sqs: 'trigger_sqs',
gcp: 'trigger_gcp',
azure: 'trigger_azure'
} as const satisfies Record<TriggerKind, UserDraftItemKind>
const TRIGGER_KIND_BY_DRAFT_KIND = {
trigger_http: 'http',
trigger_websocket: 'websocket',
trigger_kafka: 'kafka',
trigger_nats: 'nats',
trigger_postgres: 'postgres',
trigger_mqtt: 'mqtt',
trigger_sqs: 'sqs',
trigger_gcp: 'gcp',
trigger_azure: 'azure'
} as const satisfies Partial<Record<UserDraftItemKind, TriggerKind>>
const SHARED_DRAFT_KINDS = [
'script',
'flow',
'raw_app',
'trigger_schedule',
'trigger_http',
'trigger_websocket',
'trigger_kafka',
'trigger_nats',
'trigger_postgres',
'trigger_mqtt',
'trigger_sqs',
'trigger_gcp',
'trigger_azure',
'resource',
'variable'
] as const satisfies UserDraftItemKind[]
const DEFAULT_APP_DATA = { tables: [], datatable: undefined, schema: undefined }
function clone<T>(value: T): T {
return structuredClone(value) as T
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function isSharedWorkspaceItemType(type: WorkspaceItemType): type is SharedWorkspaceItemType {
return (
type === 'script' ||
type === 'flow' ||
type === 'app' ||
type === 'schedule' ||
type === 'trigger' ||
type === 'resource' ||
type === 'variable'
)
}
function sharedDraftKind(
type: SharedWorkspaceItemType,
triggerKind?: TriggerKind
): UserDraftItemKind | undefined {
switch (type) {
case 'script':
case 'flow':
return type
case 'app':
return 'raw_app'
case 'schedule':
return 'trigger_schedule'
case 'trigger':
return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined
case 'resource':
case 'variable':
return type
}
}
function sharedDraftKindsForTypes(types?: readonly WorkspaceItemType[]): UserDraftItemKind[] {
if (!types) return [...SHARED_DRAFT_KINDS]
const itemKinds = new Set<UserDraftItemKind>()
for (const type of types) {
switch (type) {
case 'script':
case 'flow':
case 'resource':
case 'variable':
itemKinds.add(type)
break
case 'app':
itemKinds.add('raw_app')
break
case 'schedule':
itemKinds.add('trigger_schedule')
break
case 'trigger':
for (const itemKind of Object.values(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND)) {
itemKinds.add(itemKind)
}
break
}
}
return [...itemKinds]
}
function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue {
return {
...value,
files: { ...(value.files ?? {}) },
runnables: { ...(value.runnables ?? {}) },
data: value.data ?? { ...DEFAULT_APP_DATA }
}
}
function getItemSummary(value: unknown): string | undefined {
return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined
}
function getItemPath(storagePath: string, value: unknown): string | undefined {
const valuePath = (value as { path?: string | null } | undefined)?.path
return valuePath?.trim() || storagePath || undefined
}
function scriptDraftToWorkspaceItem(
storagePath: string,
draft: NewScript,
isDraft = true
): WorkspaceItem | undefined {
const path = getItemPath(storagePath, draft)
if (!path) return undefined
return {
type: 'script',
path,
summary: draft.summary,
language: draft.language,
value: draft.content,
isDraft
}
}
function getFlowValue(value: unknown): FlowValue | undefined {
if (!isRecord(value) || !Array.isArray(value.modules)) return undefined
return clone(value as FlowValue)
}
function getFlowGroups(value: FlowValue): FlowValue['groups'] | null {
return Array.isArray(value.groups) ? clone(value.groups) : null
}
function getFlowSchema(value: unknown): Record<string, any> | null {
return isRecord(value) ? clone(value as Record<string, any>) : null
}
function flowDraftToWorkspaceItem(
storagePath: string,
draft: unknown,
isDraft = true
): WorkspaceItem | undefined {
if (!isRecord(draft)) return undefined
const value = getFlowValue(draft.value) ?? getFlowValue(draft)
if (!value) return undefined
const path = getItemPath(storagePath, draft)
if (!path) return undefined
return {
type: 'flow',
path,
summary: typeof draft.summary === 'string' ? draft.summary : undefined,
value: {
value,
schema: getFlowSchema(draft.schema),
groups: Array.isArray(draft.groups)
? clone(draft.groups as FlowValue['groups'])
: getFlowGroups(value)
},
isDraft
}
}
function appDraftToWorkspaceItem(
storagePath: string,
draft: AppDraftValue,
isDraft = true
): WorkspaceItem | undefined {
const value = normalizeAppDraftValue(draft)
const path = getItemPath(storagePath, value)
if (!path) return undefined
return {
type: 'app',
path,
summary: value.summary,
value,
isDraft
}
}
function scheduleDraftToWorkspaceItem(
storagePath: string,
draft: NewSchedule,
isDraft = true
): WorkspaceItem | undefined {
const path = getItemPath(storagePath, draft)
if (!path) return undefined
return {
type: 'schedule',
path,
summary: draft.summary ?? undefined,
value: clone(draft),
isDraft
}
}
function triggerDraftToWorkspaceItem(
kind: TriggerKind,
storagePath: string,
draft: TriggerRequestBody,
isDraft = true
): WorkspaceItem | undefined {
const path = getItemPath(storagePath, draft)
if (!path) return undefined
return {
type: 'trigger',
triggerKind: kind,
path,
summary: getItemSummary(draft),
value: clone(draft),
isDraft
}
}
function resourceDraftToWorkspaceItem(
storagePath: string,
draft: CreateResource,
isDraft = true
): WorkspaceItem | undefined {
const path = getItemPath(storagePath, draft)
if (!path) return undefined
return {
type: 'resource',
path,
summary: draft.description ?? undefined,
value: clone(draft),
isDraft
}
}
function variableDraftToWorkspaceItem(
storagePath: string,
draft: CreateVariable,
isDraft = true
): WorkspaceItem | undefined {
const path = getItemPath(storagePath, draft)
if (!path) return undefined
return {
type: 'variable',
path,
summary: draft.description ?? undefined,
value: clone(draft),
isDraft
}
}
function sharedDraftEntryToWorkspaceItem(
entry: UserDraftListEntry,
isDraft = true
): WorkspaceItem | undefined {
switch (entry.itemKind) {
case 'script':
return scriptDraftToWorkspaceItem(entry.storagePath, entry.value as NewScript, isDraft)
case 'flow':
return flowDraftToWorkspaceItem(entry.storagePath, entry.value, isDraft)
case 'raw_app':
return appDraftToWorkspaceItem(entry.storagePath, entry.value as AppDraftValue, isDraft)
case 'trigger_schedule':
return scheduleDraftToWorkspaceItem(entry.storagePath, entry.value as NewSchedule, isDraft)
case 'resource':
return resourceDraftToWorkspaceItem(entry.storagePath, entry.value as CreateResource, isDraft)
case 'variable':
return variableDraftToWorkspaceItem(entry.storagePath, entry.value as CreateVariable, isDraft)
default:
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind]
return triggerKind
? triggerDraftToWorkspaceItem(
triggerKind,
entry.storagePath,
entry.value as TriggerRequestBody,
isDraft
)
: undefined
}
}
type LocalWorkspaceItemLookup = {
storagePath: string
source: UserDraftEntrySource
draftSource?: UserDraftListEntry['draftSource']
item: WorkspaceItem
}
function isDeployableEntry(entry: Pick<LocalWorkspaceItemLookup, 'source' | 'draftSource'>): boolean {
return entry.source !== 'live' && entry.draftSource === 'external'
}
function itemHasTarget(
item: WorkspaceItem,
type: SharedWorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): boolean {
if (item.type !== type) return false
if (item.path !== path) return false
return type !== 'trigger' || item.triggerKind === triggerKind
}
function listSharedEntries(
workspace: string,
itemKinds: UserDraftItemKind[],
isDraft: boolean
): LocalWorkspaceItemLookup[] {
const entries: LocalWorkspaceItemLookup[] = []
for (const entry of UserDraft.list({ workspace, itemKinds })) {
const item = sharedDraftEntryToWorkspaceItem(entry, isDraft)
if (!item) continue
entries.push({
storagePath: entry.storagePath,
source: entry.source,
draftSource: entry.draftSource,
item
})
}
return entries
}
function findSharedEntry(
workspace: string,
type: SharedWorkspaceItemType,
path: string,
triggerKind: TriggerKind | undefined,
mode: 'draft' | 'current'
): LocalWorkspaceItemLookup | undefined {
const itemKind = sharedDraftKind(type, triggerKind)
if (!itemKind) return undefined
for (const entry of listSharedEntries(workspace, [itemKind], mode === 'draft')) {
if (mode === 'draft' && !isDeployableEntry(entry)) continue
if (itemHasTarget(entry.item, type, path, triggerKind)) return entry
}
return undefined
}
function getSharedItem(
workspace: string,
type: SharedWorkspaceItemType,
path: string,
triggerKind?: TriggerKind,
mode: 'draft' | 'current' = 'draft'
): WorkspaceItem | undefined {
return findSharedEntry(workspace, type, path, triggerKind, mode)?.item
}
function deleteSharedDraft(
workspace: string,
type: SharedWorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): void {
const itemKind = sharedDraftKind(type, triggerKind)
if (!itemKind) return
const draft = findSharedEntry(workspace, type, path, triggerKind, 'current')
UserDraft.clear(itemKind, draft?.storagePath ?? path, { workspace })
}
export function getGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
if (isSharedWorkspaceItemType(type)) {
return getSharedItem(workspace, type, path, triggerKind, 'draft')
}
return undefined
}
/**
* Returns the freshest frontend-visible value for AI context. This may be a
* real local draft, an AI-written UserDraft, or a clean live editor baseline.
* Therefore the returned item is intentionally not marked as `isDraft`.
*/
export function getGlobalCurrentItem(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
if (isSharedWorkspaceItemType(type)) {
return getSharedItem(workspace, type, path, triggerKind, 'current')
}
return undefined
}
export function getGlobalDraftStoragePath(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string | undefined {
if (isSharedWorkspaceItemType(type)) {
return findSharedEntry(workspace, type, path, triggerKind, 'current')?.storagePath
}
return undefined
}
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
const drafts = new Map<string, WorkspaceItem>()
for (const entry of listSharedEntries(workspace, [...SHARED_DRAFT_KINDS], true)) {
if (!isDeployableEntry(entry)) continue
const { item: draft } = entry
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
}
return Array.from(drafts.values())
}
/**
* Lists local/current entries for AI context. Entries are not marked as
* `isDraft` because UserDraft.list can include clean live editor baselines.
*/
export function listGlobalCurrentItems(
workspace: string,
types?: readonly WorkspaceItemType[]
): WorkspaceItem[] {
const items = new Map<string, WorkspaceItem>()
for (const { item } of listSharedEntries(
workspace,
sharedDraftKindsForTypes(types),
false
)) {
items.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item)
}
return Array.from(items.values())
}
export function deleteGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): void {
if (isSharedWorkspaceItemType(type)) {
deleteSharedDraft(workspace, type, path, triggerKind)
}
}
export function clearGlobalDrafts(workspace: string): void {
for (const draft of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) {
if (!isDeployableEntry(draft)) continue
UserDraft.clear(draft.itemKind, draft.storagePath, { workspace })
}
}
export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind {
return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind]
}
@@ -0,0 +1,102 @@
import type {
AzureTriggerData,
CreateResource,
CreateVariable,
FlowValue,
GcpTriggerData,
NewHttpTrigger,
NewKafkaTrigger,
NewMqttTrigger,
NewNatsTrigger,
NewPostgresTrigger,
NewSchedule,
NewSqsTrigger,
NewWebsocketTrigger,
Policy,
ScriptLang
} from '$lib/gen/types.gen'
/**
* Flow draft value. Mirrors what the backend's create/update flow API expects
* -- the OpenFlow value, plus the inputs schema and (optional) groups.
*
* Schema and groups are split out from FlowValue intentionally so that
* deploy_workspace_item can preserve them through the draft -> workspace
* round-trip; an earlier version dropped them on every deploy.
*/
export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
}
export const TRIGGER_KINDS = [
'http',
'websocket',
'kafka',
'nats',
'postgres',
'mqtt',
'sqs',
'gcp',
'azure'
] as const
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
export type TriggerRequestBody =
| NewHttpTrigger
| NewWebsocketTrigger
| NewKafkaTrigger
| NewNatsTrigger
| NewPostgresTrigger
| NewMqttTrigger
| NewSqsTrigger
| GcpTriggerData
| AzureTriggerData
export type WorkspaceItemType =
| 'script'
| 'flow'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
| 'app'
export type AppDraftValue = {
summary?: string
files: Record<string, string>
runnables: Record<string, any>
data?: any
policy?: Policy
custom_path?: string
}
export type WorkspaceItem = {
type: WorkspaceItemType
path: string
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
value?:
| string
| FlowDraftValue
| NewSchedule
| TriggerRequestBody
| CreateResource
| CreateVariable
| AppDraftValue
isDraft: boolean
}
export function getWorkspaceItemKey(
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
if (type === 'trigger') {
return `trigger:${triggerKind ?? ''}:${path}`
}
return `${type}:${path}`
}
@@ -33,7 +33,7 @@
async function importRaw() {
$importFlowStore =
importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '')
await goto('/flows/add')
await goto('/flows/add?nodraft=true')
drawer?.closeDrawer?.()
}
@@ -41,7 +41,7 @@
const parsed =
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '')
$importScriptStore = parsed
await goto(`${base}/scripts/add?import=true`)
await goto(`${base}/scripts/add?import=true&nodraft=true`)
wacDrawer?.closeDrawer?.()
}
@@ -7,7 +7,6 @@
import type Drawer from '../common/drawer/Drawer.svelte'
import { type Policy, WorkspaceService } from '$lib/gen'
import DiffDrawer from '../DiffDrawer.svelte'
import { encodeState } from '$lib/utils'
import { deepEqual } from 'fast-equals'
// import { addWmillClient } from './utils'
@@ -169,25 +168,6 @@
})
historyManager.manualSnapshot(files ?? {}, runnables, summary, data)
let draftTimeout: number | undefined = undefined
function saveFrontendDraft() {
draftTimeout && clearTimeout(draftTimeout)
draftTimeout = setTimeout(() => {
try {
localStorage.setItem(
path != '' ? `rawapp-${path}` : 'rawapp',
encodeState({
files,
runnables: runnables,
data: data
})
)
} catch (err) {
console.error(err)
}
}, 500)
}
let iframe: HTMLIFrameElement | undefined = $state(undefined)
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
@@ -393,7 +373,6 @@
if (data.datatable !== policy.datatable || data.schema !== policy.schema) {
data.datatable = policy.datatable
data.schema = policy.schema
saveFrontendDraft()
}
})
@@ -634,7 +613,6 @@
// Only add if not already present
if (!data.tables.includes(newRef)) {
data.tables = [...data.tables, newRef]
saveFrontendDraft()
// Clear the cached schema so it gets refreshed with the new table
const resourcePath = `datatable://${datatableName}`
delete $dbSchemas[resourcePath]
@@ -664,7 +642,6 @@
// Only add if not already present
if (!data.tables.includes(newRef)) {
data.tables = [...data.tables, newRef]
saveFrontendDraft()
void aiChatManager.refreshDatatables()
}
}
@@ -752,9 +729,6 @@
}
let darkMode: boolean = $state(false)
$effect(() => {
runnables && files && saveFrontendDraft()
})
$effect(() => {
iframe?.addEventListener('load', () => {
iframeLoaded = true
@@ -959,7 +933,6 @@
dataTableRefs={dataTableRefsObjects}
onDataTableRefsChange={(newRefs) => {
data.tables = newRefs.map(formatDataTableRef)
saveFrontendDraft()
}}
defaultDatatable={data.datatable}
defaultSchema={data.schema}
@@ -972,7 +945,6 @@
datatable,
schema
}
saveFrontendDraft()
}}
{runnables}
{modules}
@@ -5,6 +5,7 @@
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { rawAppToHubUrl } from '$lib/hub'
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import YAML from 'yaml'
@@ -242,14 +243,10 @@
}
closeSaveDrawer()
sendUserToast('App deployed successfully')
try {
localStorage.removeItem(`rawapp-${path}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
UserDraft.remove('raw_app', path)
dispatch('savedNewAppPath', path)
} catch (e) {
sendUserToast('Error creating app', e)
sendUserToast(`Error creating app: ${e.body ?? e.message}`, true)
}
}
@@ -357,12 +354,8 @@
closeSaveDrawer()
sendUserToast('App deployed successfully')
UserDraft.remove('raw_app', appPath)
if (appPath !== npath) {
try {
localStorage.removeItem(`rawapp-${appPath}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
dispatch('savedNewAppPath', npath)
}
}
@@ -440,9 +433,13 @@
}
draftDrawerOpen = false
// The initial draft was promoted to a real path on the backend —
// drop the autosave keyed on the prior (possibly empty) path so
// a future "+ App" click opens on a clean slate.
UserDraft.remove('raw_app', appPath)
dispatch('savedNewAppPath', newEditedPath)
} catch (e) {
sendUserToast('Error saving initial draft', e)
sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true)
}
draftDrawerOpen = false
}
@@ -540,11 +537,7 @@
}
sendUserToast('Draft saved')
try {
localStorage.removeItem(`rawapp-${path}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
UserDraft.remove('raw_app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
dispatch('savedNewAppPath', newEditedPath || path)
@@ -32,7 +32,6 @@ export interface ScriptBuilderProps {
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
searchParams?: URLSearchParams
disableHistoryChange?: boolean
replaceStateFn?: (url: string) => void
customUi?: ScriptBuilderWhitelabelCustomUi
savedPrimarySchedule?: ScheduleTrigger | undefined
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
@@ -14,7 +14,7 @@
unifiedSize="lg"
variant="accent"
startIcon={{ icon: Plus }}
href="{base}/scripts/add"
href="{base}/scripts/add?nodraft=true"
endIcon={{ icon: Code2 }}
>
Script
@@ -25,6 +25,7 @@
import { saveAzureTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { base } from '$lib/base'
@@ -106,6 +107,16 @@
let hasChanged = $derived(!deepEqual(getAzureConfig(), originalConfig ?? {}))
const azureConfig = $derived.by(getAzureConfig)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_azure',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => azureConfig,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const saveDisabled = $derived(
pathError != '' || emptyString(script_path) || !isValid || !can_write || !hasChanged
)
@@ -124,14 +135,15 @@
edit = true
dirtyPath = false
await loadTrigger(defaultValues)
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getAzureConfig()))
}
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load Azure trigger: ${err.body}`, true)
} finally {
drawerLoading = false
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getAzureConfig()))
}
}
}
@@ -210,6 +222,7 @@
async function updateTrigger(): Promise<void> {
deploymentLoading = true
const previousPath = initialPath
const cfg = azureConfig
if (!cfg) return
const isSaved = await saveAzureTriggerFromCfg(
@@ -220,6 +233,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getAzureConfig())
onUpdate?.(cfg.path)
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
initialPath = cfg.path
@@ -29,6 +29,7 @@
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { saveEmailTriggerFromCfg } from './utils'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -88,6 +89,16 @@
let hasChanged = $derived(!deepEqual(getEmailTriggerConfig(), originalConfig ?? {}))
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const emailConfig = $derived.by(getEmailTriggerConfig)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_email',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => emailConfig,
applyCfg: (c) => loadTriggerConfig(c as Partial<EmailTrigger>),
deployed: () => originalConfig
})
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
const saveDisabled = $derived(
drawerLoading ||
@@ -120,14 +131,15 @@
dirtyPath = false
dirtyLocalPart = false
await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
} catch (err) {
sendUserToast(`Could not load email trigger: ${err}`, true)
} finally {
if (!defaultConfig) {
// If the email trigger is loaded from the backend, we to set the initial config
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
}
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load email trigger: ${err}`, true)
} finally {
clearTimeout(loader)
drawerLoading = false
showLoader = false
@@ -213,6 +225,7 @@
drawer?.closeDrawer()
} else {
deploymentLoading = true
const previousPath = initialPath
const saveCfg = emailConfig
const isSaved = await saveEmailTriggerFromCfg(
initialPath,
@@ -223,6 +236,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getEmailTriggerConfig())
onUpdate(saveCfg.path)
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
initialPath = saveCfg.path
@@ -27,6 +27,7 @@
import { saveGcpTriggerFromCfg } from './utils'
import { getHandlerType, handleConfigChange, type Trigger } from '../utils'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { base } from '$lib/base'
@@ -108,6 +109,16 @@
let hasChanged = $derived(!deepEqual(getGcpConfig(), originalConfig ?? {}))
const gcpConfig = $derived.by(getGcpConfig)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_gcp',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => gcpConfig,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const saveDisabled = $derived(
pathError != '' || emptyString(script_path) || !isValid || !can_write || !hasChanged
)
@@ -126,14 +137,15 @@
edit = true
dirtyPath = false
await loadTrigger(defaultValues)
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getGcpConfig()))
}
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true)
} finally {
drawerLoading = false
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getGcpConfig()))
}
}
}
@@ -217,6 +229,7 @@
async function updateTrigger(): Promise<void> {
deploymentLoading = true
const previousPath = initialPath
const cfg = gcpConfig
if (!cfg) {
return
@@ -229,6 +242,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getGcpConfig())
onUpdate?.(cfg.path)
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
initialPath = cfg.path
@@ -55,6 +55,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import UserSettings from '$lib/components/UserSettings.svelte'
@@ -137,6 +138,16 @@
let scopes = $derived(['http_triggers:read:' + path])
const routeConfig = $derived.by(getRouteConfig)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_http',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => routeConfig,
applyCfg: (c) => loadTriggerConfig(c as Partial<HttpTrigger>),
deployed: () => originalConfig
})
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
const saveDisabled = $derived(
drawerLoading ||
@@ -219,14 +230,15 @@
dirtyPath = false
dirtyRoutePath = false
await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
} catch (err) {
sendUserToast(`Could not load route: ${err}`, true)
} finally {
if (!defaultConfig) {
// If the route is loaded from the backend, we to set the initial config
initialConfig = structuredClone($state.snapshot(getRouteConfig()))
}
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load route: ${err}`, true)
} finally {
clearTimeout(loader)
drawerLoading = false
showLoader = false
@@ -346,6 +358,7 @@
drawer?.closeDrawer()
} else {
deploymentLoading = true
const previousPath = initialPath
const saveCfg = routeConfig
const isSaved = await saveHttpRouteFromCfg(
initialPath,
@@ -356,6 +369,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getRouteConfig())
onUpdate(saveCfg.path)
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
initialPath = saveCfg.path
@@ -24,6 +24,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import TriggerFilters from '../TriggerFilters.svelte'
@@ -126,6 +127,16 @@
!hasChanged
)
const kafkaConfig = $derived.by(getSaveCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_kafka',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => kafkaConfig,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
$effect(() => {
@@ -148,13 +159,14 @@
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
} catch (err) {
sendUserToast(`Could not load Kafka trigger: ${err}`, true)
} finally {
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load Kafka trigger: ${err}`, true)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
@@ -269,6 +281,7 @@
async function updateTrigger(): Promise<void> {
deploymentLoading = true
const previousPath = initialPath
const cfg = getSaveCfg()
const isSaved = await saveKafkaTriggerFromCfg(
initialPath,
@@ -278,6 +291,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getSaveCfg())
onUpdate?.(cfg.path)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
initialPath = cfg.path
@@ -39,6 +39,7 @@
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
interface Props {
useDrawer?: boolean
@@ -115,6 +116,16 @@
let hasChanged = $derived(!deepEqual(getSaveCfg(), originalConfig ?? {}))
const mqttConfig = $derived.by(getSaveCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_mqtt',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => mqttConfig,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
const saveDisabled = $derived(
pathError != '' || emptyString(script_path) || !can_write || !isValid || !hasChanged
@@ -144,13 +155,14 @@
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
} catch (err) {
sendUserToast(`Could not load mqtt trigger: ${err.body}`, true)
} finally {
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load mqtt trigger: ${err.body}`, true)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
@@ -282,6 +294,7 @@
async function updateTrigger(): Promise<void> {
deploymentLoading = true
const previousPath = initialPath
const cfg = getSaveCfg()
const isSaved = await saveMqttTriggerFromCfg(
initialPath,
@@ -291,6 +304,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getSaveCfg())
onUpdate?.(cfg.path)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
initialPath = cfg.path
@@ -23,6 +23,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
@@ -110,6 +111,16 @@
pathError != '' || emptyString(script_path) || !can_write || !isValid || !hasChanged
)
const natsConfig = $derived.by(getSaveCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_nats',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => natsConfig,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
$effect(() => {
@@ -132,16 +143,17 @@
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load nats trigger: ${err}`, true)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
}
}
@@ -248,6 +260,7 @@
async function updateTrigger(): Promise<void> {
deploymentLoading = true
const previousPath = initialPath
const cfg = natsConfig
const isSaved = await saveNatsTriggerFromCfg(
initialPath,
@@ -257,6 +270,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getSaveCfg())
onUpdate?.(cfg.path)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
initialPath = cfg.path
@@ -45,6 +45,7 @@
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import { capitalize } from '$lib/utils'
interface Props {
@@ -162,6 +163,16 @@
)
const postgresConfig = $derived.by(getSaveCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_postgres',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => postgresConfig,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
const saveDisabled = $derived(
@@ -239,13 +250,14 @@
transaction_to_track = []
tab = 'basic'
await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
} catch (err) {
sendUserToast(`Could not load postgres trigger: ${err.body}`, true)
} finally {
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load postgres trigger: ${err.body}`, true)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
@@ -400,6 +412,7 @@
if (!cfg) {
return
}
const previousPath = initialPath
deploymentLoading = true
const isSaved = await savePostgresTriggerFromCfg(
initialPath,
@@ -409,6 +422,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getSaveCfg())
onUpdate?.(path)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
initialPath = cfg.path
@@ -38,6 +38,7 @@
import { runScheduleNow } from '../scheduled/utils'
import { handleConfigChange } from '../utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { twMerge } from 'tailwind-merge'
import PermissionedAsLine from '../PermissionedAsLine.svelte'
@@ -130,6 +131,16 @@
)
const scheduleCfg = $derived.by(getScheduleCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_schedule',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => scheduleCfg,
applyCfg: loadScheduleCfg,
deployed: () => initialConfig
})
export async function openEdit(ePath: string, isFlow: boolean, defaultCfg?: Record<string, any>) {
let loadingTimeout = setTimeout(() => {
showLoading = true
@@ -142,10 +153,11 @@
path = defaultCfg?.path ?? ePath
await loadSchedule(defaultCfg)
edit = true
} finally {
if (!defaultCfg) {
initialConfig = structuredClone($state.snapshot(getScheduleCfg()))
}
await draftSync.maybeRestore(ePath)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
@@ -527,10 +539,12 @@
}
async function scheduleScript(): Promise<void> {
const previousPath = initialPath
const scheduleCfg = getScheduleCfg()
deploymentLoading = true
const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, $workspaceStore!)
if (isSaved) {
draftSync.discard(previousPath, scheduleCfg)
onUpdate?.(scheduleCfg.path)
drawer?.closeDrawer()
}
@@ -31,6 +31,7 @@
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
interface Props {
useDrawer?: boolean
@@ -102,6 +103,16 @@
let originalConfig = $state<Record<string, any> | undefined>(undefined)
const sqsConfig = $derived.by(getSaveCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_sqs',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => sqsConfig,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const captureConfig = $derived.by(getCaptureConfig)
const hasChanged = $derived(!deepEqual(sqsConfig, originalConfig ?? {}))
const saveDisabled = $derived(
@@ -127,13 +138,17 @@
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
} catch (err) {
sendUserToast(`Could not load sqs trigger: ${err.body}`, true)
} finally {
// Snapshot the *backend* config as the baseline before overlaying
// any local autosave, so hasChanged / onConfigChange correctly
// flag the local edits as unsaved changes.
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load sqs trigger: ${err.body}`, true)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
@@ -267,6 +282,7 @@
async function updateTrigger(): Promise<void> {
deploymentLoading = true
const previousPath = initialPath
const cfg = getSaveCfg()
const isSaved = await saveSqsTriggerFromCfg(
initialPath,
@@ -276,6 +292,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getSaveCfg())
onUpdate?.(cfg.path)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
initialPath = cfg.path
@@ -307,6 +324,11 @@
handleConfigChange(sqsConfig, initialConfig, saveDisabled, edit, onConfigChange)
}
})
// Persist edits to UserDraft so an accidental drawer close doesn't lose
// in-progress work. Skipped while the drawer is loading (the freshly
// loaded backend value isn't a user edit) and for new triggers without
// a path (no localStorage write would happen anyway).
</script>
{#if mode === 'suspended'}
@@ -39,16 +39,10 @@ export class Triggers {
? this.#triggers[this.#selectedTriggerIndex]
: undefined
)
#updateDraftCallback: (() => void) | undefined = undefined
constructor(
triggers: Trigger[] = [],
selectedIndex?: number,
updateDraftCallback?: (() => void) | undefined
) {
constructor(triggers: Trigger[] = [], selectedIndex?: number) {
this.#triggers = triggers
this.#selectedTriggerIndex = selectedIndex
this.#updateDraftCallback = updateDraftCallback
}
get selectedTrigger(): Trigger | undefined {
@@ -65,7 +59,6 @@ export class Triggers {
} else {
this.#selectedTriggerIndex = index
}
this.#updateDraftCallback?.()
}
get triggers(): Trigger[] {
@@ -74,16 +67,13 @@ export class Triggers {
setTriggers(triggers: Trigger[]) {
this.#triggers = triggers
this.#updateDraftCallback?.()
}
setDraftConfig(triggerIndex: number, draftConfig: Record<string, any> | undefined) {
console.log('setDraftConfig', triggerIndex, draftConfig)
if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) {
return
}
this.#triggers[triggerIndex].draftConfig = draftConfig
this.#updateDraftCallback?.()
}
getDraftTriggersSnapshot(): Trigger[] | undefined {
@@ -116,7 +106,6 @@ export class Triggers {
}
this.#triggers.push(newTrigger)
this.#updateDraftCallback?.()
updateTriggersCount(triggersCountStore, type, 'add', newTrigger.draftConfig)
@@ -135,7 +124,6 @@ export class Triggers {
this.#triggers = this.#triggers.filter((_, index) => index !== triggerIndex)
updateTriggersCount(triggersCountStore, type, 'remove')
this.#updateDraftCallback?.()
}
updateTriggers(
@@ -172,7 +160,6 @@ export class Triggers {
const newTriggers = sortTriggers([...filteredTriggers, ...backendTriggers])
this.#triggers = newTriggers
this.#updateDraftCallback?.()
return newTriggers.filter((t) => t.type === type).length
}
@@ -0,0 +1,121 @@
import { untrack } from 'svelte'
import { UserDraft, localDraftDiffers, type UserDraftItemKind } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
type Cfg = Record<string, any>
export interface TriggerDraftSyncOptions {
/** UserDraft item kind for this trigger, e.g. `'trigger_postgres'`. */
itemKind: UserDraftItemKind
/** Reactive editor path (the trigger being edited). */
path: () => string
/** Reactive workspace ($workspaceStore). */
workspace: () => string | undefined
/** Reactive loading flag — both effects are inert while true. */
drawerLoading: () => boolean
/** form → config: the editor's `getXCfg()` (or its `$derived`). */
getCfg: () => Cfg | undefined
/** config → form: the editor's `loadXConfig` / `loadScheduleCfg`. May be async. */
applyCfg: (cfg: Cfg) => void | Promise<void>
/**
* The deployed baseline the editor's dirty check compares against
* `originalConfig` for TriggerCrud editors, `initialConfig` for Schedule.
*/
deployed: () => Cfg | undefined
}
export interface TriggerDraftSync {
/** The local autosave for the active (workspace, path), if any. */
readonly draft: Cfg | undefined
/**
* Restore-on-open: if a local autosave diverges from the just-loaded
* backend config, overlay it and toast a "Reset to deployed" action.
* Call right after the backend load, before clearing `drawerLoading`.
*/
maybeRestore(path: string): Promise<void>
/**
* Clear the draft for `path` and reset the handle's in-memory cell to
* `fallback`. Use after a successful deploy, passing the just-saved cfg
* `discard` (not `UserDraft.remove`) so the apply-effect doesn't bounce
* the form back to the now-stale draft.
*/
discard(path: string, fallback: Cfg | undefined): void
}
/**
* Shared local-autosave wiring for the trigger editors. Holding a live
* `UserDraft` handle is what makes an external `UserDraft.saveExternal('trigger_x',
* )` (another tab, a programmatic write) propagate into the open editor.
*
* - **apply-effect**: reflects external `handle.draft` changes into the form.
* - **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`
* idempotence so they can't feed back into each other. Must be called once
* during component init (it registers `useMany` + two `$effect`s).
*/
export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraftSync {
const handles = UserDraft.useMany<Cfg>(() => {
const p = opts.path()
const ws = opts.workspace()
return p && ws ? [{ itemKind: opts.itemKind, path: p, workspace: ws }] : []
})
const handle = $derived(handles[0])
function discard(path: string, fallback: Cfg | undefined): void {
UserDraft.discard(opts.itemKind, path, fallback, {
workspace: opts.workspace() ?? undefined
})
}
// apply-effect: external handle.draft → form.
$effect(() => {
const d = handle?.draft
if (opts.drawerLoading() || d == null) return
untrack(() => {
if (localDraftDiffers(d, opts.getCfg() as Cfg)) {
void opts.applyCfg(d)
}
})
})
// persist-effect: form edits → handle; drop the draft when back at the
// deployed baseline.
$effect(() => {
if (opts.drawerLoading() || !opts.path()) return
const cfg = opts.getCfg()
if (cfg == null) return
untrack(() => {
const h = handle
if (!h) return
const deployed = opts.deployed()
if (localDraftDiffers(cfg, deployed)) {
if (localDraftDiffers(cfg, h.draft)) h.draft = cfg
} else {
discard(opts.path(), deployed)
}
})
})
return {
get draft() {
return handle?.draft
},
async maybeRestore(path: string) {
const d = handle?.draft
if (!localDraftDiffers(d, opts.getCfg() as Cfg)) return
// Snapshot the just-loaded backend config so "Reset to deployed"
// can re-apply it, then overlay the local autosave.
const deployedCfg = structuredClone($state.snapshot(opts.getCfg())) as Cfg
await opts.applyCfg(d)
notifyRestoredFromLocal(false, true, {
onResetToDeployed: async () => {
discard(path, deployedCfg)
await opts.applyCfg(deployedCfg)
}
})
},
discard
}
}
@@ -42,6 +42,7 @@
import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import { deepEqual } from 'fast-equals'
import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte'
import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte'
import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte'
import { capitalize } from '$lib/utils'
@@ -130,6 +131,16 @@
let hasChanged = $derived(!deepEqual(getSaveCfg(), originalConfig ?? {}))
const websocketCfg = $derived.by(getSaveCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_websocket',
path: () => initialPath,
workspace: () => $workspaceStore,
drawerLoading: () => drawerLoading,
getCfg: () => websocketCfg,
applyCfg: loadTriggerConfig,
deployed: () => originalConfig
})
const captureConfig = $derived.by(untrack(() => isEditor) ? getCaptureConfig : () => ({}))
const saveDisabled = $derived.by(() => {
const invalidInitialMessages = initial_messages.some((v) => {
@@ -176,13 +187,14 @@
dirtyPath = false
dirtyUrl = false
await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
} catch (err) {
sendUserToast(`Could not load websocket trigger: ${err}`, true)
} finally {
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore(ePath)
} catch (err) {
sendUserToast(`Could not load websocket trigger: ${err}`, true)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
@@ -342,6 +354,7 @@
async function updateTrigger(): Promise<void> {
deploymentLoading = true
const previousPath = initialPath
const saveCfg = getSaveCfg()
const isSaved = await saveWebsocketTriggerFromCfg(
initialPath,
@@ -351,6 +364,7 @@
usedTriggerKinds
)
if (isSaved) {
draftSync.discard(previousPath, getSaveCfg())
onUpdate?.(saveCfg.path)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
initialPath = saveCfg.path
@@ -25,7 +25,8 @@
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
import { updateProgress } from '$lib/tutorialUtils'
const { flowStore, flowStateStore, selectionManager, currentEditor } = getContext<FlowEditorContext>('FlowEditorContext')
const { flowStore, flowStateStore, selectionManager, currentEditor } =
getContext<FlowEditorContext>('FlowEditorContext')
interface Props {
index: number
@@ -48,7 +49,11 @@
}
// Helper function to type text character by character
async function typeText(input: HTMLInputElement, text: string, delay: number = DELAY_TYPING): Promise<void> {
async function typeText(
input: HTMLInputElement,
text: string,
delay: number = DELAY_TYPING
): Promise<void> {
input.value = ''
input.focus()
for (let i = 0; i < text.length; i++) {
@@ -60,7 +65,7 @@
// Helper function to update module summary in flowStore
function updateModuleSummary(moduleId: string, summary: string): void {
const moduleIndex = flowStore.val.value.modules.findIndex(m => m.id === moduleId)
const moduleIndex = flowStore.val.value.modules.findIndex((m) => m.id === moduleId)
if (moduleIndex !== -1) {
flowStore.val.value.modules[moduleIndex].summary = summary
flowStore.val = { ...flowStore.val }
@@ -78,9 +83,9 @@
// Helper function to find button by text and classes
function findButtonByText(text: string, classes: string[] = []): HTMLElement | null {
const buttons = Array.from(document.querySelectorAll('button'))
return buttons.find(btn => {
return buttons.find((btn) => {
const hasText = btn.textContent?.includes(text) ?? false
const hasClasses = classes.every(cls => btn.classList.contains(cls))
const hasClasses = classes.every((cls) => btn.classList.contains(cls))
return hasText && (classes.length === 0 || hasClasses)
}) as HTMLElement | null
}
@@ -127,11 +132,6 @@
}
export function runTutorial() {
try {
localStorage.removeItem('flow')
} catch (e) {
console.error('Error clearing localStorage', e)
}
tutorial?.runTutorial()
}
@@ -196,7 +196,7 @@
celsius: {
type: 'number',
description: 'Temperature in Celsius',
default: ""
default: ''
}
},
required: ['celsius'],
@@ -212,7 +212,7 @@
<Tutorial
bind:this={tutorial}
index={index}
{index}
name="flow-live-tutorial"
tainted={isFlowTainted(flowStore.val)}
on:error
@@ -260,7 +260,9 @@
overlay.style.left = '0'
}
const celsiusInput = document.querySelector('input[type="number"][placeholder=""]') as HTMLInputElement
const celsiusInput = document.querySelector(
'input[type="number"][placeholder=""]'
) as HTMLInputElement
if (celsiusInput) {
celsiusInput.value = ''
celsiusInput.dispatchEvent(new Event('input', { bubbles: true }))
@@ -312,7 +314,9 @@
await wait(DELAY_LONG)
const spans = Array.from(document.querySelectorAll('span'))
const bunSpan = spans.find(span => span.textContent?.includes('TypeScript (Bun)')) as HTMLElement
const bunSpan = spans.find((span) =>
span.textContent?.includes('TypeScript (Bun)')
) as HTMLElement
if (bunSpan) {
// Animate cursor from add step button to TypeScript (Bun) span
@@ -355,7 +359,13 @@
side: 'top',
onNextClick: () => {
if (!step3Complete) {
sendUserToast('Please wait for the script to be created...', false, [], undefined, 3000)
sendUserToast(
'Please wait for the script to be created...',
false,
[],
undefined,
3000
)
return
}
driver.moveNext()
@@ -383,7 +393,9 @@
// First, type the summary
await wait(DELAY_MEDIUM)
const summaryInput = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement
const summaryInput = document.querySelector(
'input[placeholder="Summary"]'
) as HTMLInputElement
if (summaryInput) {
const summaryText = 'Validate temperature input'
await typeText(summaryInput, summaryText)
@@ -405,8 +417,9 @@
if (editorState && editorState.type === 'script') {
const editor = editorState.editor
const moduleA = flowJson.value.modules.find(m => m.id === 'a')
const codeToType = (moduleA?.value && 'content' in moduleA.value) ? moduleA.value.content : ''
const moduleA = flowJson.value.modules.find((m) => m.id === 'a')
const codeToType =
moduleA?.value && 'content' in moduleA.value ? moduleA.value.content : ''
if (codeToType) {
editor.setCode('', true)
@@ -422,8 +435,11 @@
}
// Update the flow store with the typed code
const moduleIndex = flowStore.val.value.modules.findIndex(m => m.id === 'a')
if (moduleIndex !== -1 && 'content' in flowStore.val.value.modules[moduleIndex].value) {
const moduleIndex = flowStore.val.value.modules.findIndex((m) => m.id === 'a')
if (
moduleIndex !== -1 &&
'content' in flowStore.val.value.modules[moduleIndex].value
) {
flowStore.val.value.modules[moduleIndex].value = {
...flowStore.val.value.modules[moduleIndex].value,
content: codeToType
@@ -445,13 +461,18 @@
},
popover: {
title: 'Add validation logic',
description:
"Watch as we write code to validate the temperature input.",
description: 'Watch as we write code to validate the temperature input.',
side: 'bottom',
onNextClick: () => {
// Only proceed if code writing is complete
if (!step4Complete) {
sendUserToast('Please wait for the code to finish typing...', false, [], undefined, 3000)
sendUserToast(
'Please wait for the code to finish typing...',
false,
[],
undefined,
3000
)
return
}
@@ -524,7 +545,9 @@
await wait(DELAY_MEDIUM)
// Step 2: Move to and click flow_input.celsius
const targetButton = document.querySelector('button[title="flow_input.celsius"]') as HTMLElement
const targetButton = document.querySelector(
'button[title="flow_input.celsius"]'
) as HTMLElement
if (targetButton) {
await moveCursorToElement(fakeCursor, targetButton, DELAY_ANIMATION_LONG)
await wait(DELAY_MEDIUM)
@@ -636,7 +659,9 @@
await wait(DELAY_LONG)
// Type summary for script 'b'
const summaryInputB = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement
const summaryInputB = document.querySelector(
'input[placeholder="Summary"]'
) as HTMLInputElement
if (summaryInputB) {
const summaryTextB = 'Convert to Fahrenheit'
await typeText(summaryInputB, summaryTextB)
@@ -655,7 +680,9 @@
await wait(DELAY_LONG)
// Type summary for script 'c'
const summaryInputC = document.querySelector('input[placeholder="Summary"]') as HTMLInputElement
const summaryInputC = document.querySelector(
'input[placeholder="Summary"]'
) as HTMLInputElement
if (summaryInputC) {
const summaryTextC = 'Categorize temperature'
await typeText(summaryInputC, summaryTextC)
@@ -680,7 +707,13 @@
description: 'Two more scripts to convert and categorize the temperature.',
onNextClick: () => {
if (!step6Complete) {
sendUserToast('Please wait for the summaries to be added...', false, [], undefined, 3000)
sendUserToast(
'Please wait for the summaries to be added...',
false,
[],
undefined,
3000
)
return
}
@@ -703,7 +736,8 @@
element: '#flow-editor-test-flow',
popover: {
title: 'Ready to test!',
description: 'Run the complete flow and see your temperature converter in action.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
description:
'Run the complete flow and see your temperature converter in action.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to learn more?</strong> Access more tutorials from the <strong>Tutorials</strong> page in the main menu or in the <strong>Help</strong> submenu.</p>',
onNextClick: () => {
updateProgress(index)
driver.destroy()
@@ -712,7 +746,7 @@
sendUserToast('Previous is not available for this step', true, [], undefined, 3000)
}
}
},
}
]
return steps
-8
View File
@@ -11,12 +11,6 @@ import {
import { resetProtectionRules, loadProtectionRules } from './workspaceProtectionRules.svelte'
export function switchWorkspace(workspace: string | undefined) {
try {
localStorage.removeItem('flow')
localStorage.removeItem('app')
} catch (e) {
console.error('error interacting with local storage', e)
}
resourceTypesStore.set(undefined)
// Clear protection rules state
@@ -32,8 +26,6 @@ export function switchWorkspace(workspace: string | undefined) {
export function clearStores(): void {
try {
localStorage.removeItem('flow')
localStorage.removeItem('app')
clearWorkspaceFromStorage()
} catch (e) {
console.error('error interacting with local storage', e)
+114 -6
View File
@@ -2,7 +2,7 @@
import { untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import type { StateStore } from './utils'
import { readFieldsRecursively, type StateStore } from './utils'
import { resource, watch, type ResourceReturn } from 'runed'
export function withProps<Component, Props>(component: Component, props: Props) {
@@ -575,8 +575,36 @@ export class DebouncedTempValue<T> {
export function useLocalStorageValue<T>(
key: string,
defaultValue: T,
typ?: 'string' | 'number' | 'boolean'
): { val: T } {
typ?: 'string' | 'number' | 'boolean',
options?: {
saveInitialValue?: boolean
/**
* Coalesce localStorage writes within a sliding window. When set to a
* positive number, repeated mutations within `debounce` ms produce a
* single localStorage write at the end of the window. The in-memory
* `$state` is updated immediately on each change only the persistence
* side-effect is deferred. Readers of `.val` always see the latest
* value; readers of `localStorage` may see a stale value during the
* window. A pending write fires from a plain `setTimeout`, which
* keeps running across SPA route teardown only a hard browser tab
* close drops it.
*/
debounce?: number
/**
* Transform applied to the value just before serialisation, on every
* persist (both setter-driven and deep-mutation-driven). The in-memory
* `$state` is left as-is. Useful for injecting per-write metadata
* (timestamps, counters) that must reflect the actual write time, not
* the last `.val =` assignment deep mutations don't re-run the
* setter, so a timestamp set via `.val =` would otherwise grow stale
* across long editing sessions.
*/
transformBeforePersist?: (val: T) => T
}
): { val: T; skipNextWriteOnce(): void } {
const saveInitialValue = options?.saveInitialValue ?? true
const debounceMs = options?.debounce ?? 0
const transformBeforePersist = options?.transformBeforePersist
const serialize = (val: T) =>
typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val)
const deserialize = (val: string): T => {
@@ -585,17 +613,97 @@ export function useLocalStorageValue<T>(
if (typ === 'boolean') return (val === 'true') as any
return JSON.parse(val) as T
}
const persist = (val: T | undefined) => {
try {
if (val === undefined) {
localStorage.removeItem(key)
} else {
const toStore = transformBeforePersist ? transformBeforePersist(val as T) : (val as T)
localStorage.setItem(key, serialize(toStore))
}
} catch (e) {
console.error('useLocalStorageValue: localStorage write failed', e)
}
}
if (typeof window === 'undefined') return { val: defaultValue }
if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} }
const savedValue = localStorage.getItem(key)
let s = $state(savedValue ? (deserialize(savedValue) as T) : defaultValue)
let s = $state<T>(
savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue
)
// Track the serialized form last written so we can detect deep mutations
// (changes that didn't go through the setter) without double-writing on
// every setter call. The first effect run sees identical serialized output
// and is a no-op (avoids persisting the default value on mount).
let lastSerialized: string | undefined = untrack(() =>
s === undefined ? undefined : serialize(s)
)
// When saveInitialValue=false, the first time the value actually changes
// (either via the setter or a deep mutation) is treated as "loading the
// initial value" rather than a user edit — we update lastSerialized so
// future writes are detected, but we don't persist.
let skipNextWrite = !saveInitialValue
// Debounce wrapper. Captures the latest pending value; a follow-up call
// within the window replaces the queued payload and resets the timer.
let debounceTimer: ReturnType<typeof setTimeout> | undefined
let pendingValue: T | undefined
const schedulePersist = (val: T | undefined) => {
if (debounceMs <= 0) {
persist(val)
return
}
pendingValue = val
if (debounceTimer != null) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = undefined
persist(pendingValue)
pendingValue = undefined
}, debounceMs)
}
$effect(() => {
readFieldsRecursively(s)
const next = s === undefined ? undefined : serialize(s)
if (next === lastSerialized) return
lastSerialized = next
if (skipNextWrite) {
skipNextWrite = false
return
}
schedulePersist(s)
})
return {
get val() {
return s
},
set val(newVal: T) {
localStorage.setItem(key, serialize(newVal))
// In-memory state is updated synchronously; the localStorage write
// is debounced when `options.debounce` is set. Callers that read
// `.val` get the latest value either way; only direct localStorage
// reads see the stale value during the debounce window.
const next = newVal === undefined ? undefined : serialize(newVal as T)
if (next !== lastSerialized) {
lastSerialized = next
if (skipNextWrite) {
skipNextWrite = false
} else {
schedulePersist(newVal)
}
}
s = newVal
},
/**
* Arm the persist skip so the next `set val` (or deep-mutation flush)
* updates only the in-memory cell and leaves localStorage untouched.
* Used by `UserDraft.discard` to reset the in-memory state to a
* fallback without re-persisting it pairs with an explicit LS
* delete to leave the slot empty.
*/
skipNextWriteOnce(): void {
skipNextWrite = true
}
}
}
+10
View File
@@ -61,6 +61,16 @@ Object.defineProperty(globalThis, 'sessionStorage', {
writable: true
})
// Some modules (e.g. svelte5Utils.useLocalStorageValue) gate browser-only
// behavior on `typeof window`. Provide a minimal window so they don't
// short-circuit during tests.
if (typeof (globalThis as any).window === 'undefined') {
Object.defineProperty(globalThis, 'window', {
value: globalThis,
writable: true
})
}
vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features/worker', () => ({
TypeScriptWorker: class TypeScriptWorker {
private _mockScriptSnapshot?: {
+821
View File
@@ -0,0 +1,821 @@
import { get } from 'svelte/store'
import { onDestroy, untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import { workspaceStore } from './stores'
import { useLocalStorageValue } from './svelte5Utils.svelte'
export type UserDraftItemKind =
| 'script'
| 'flow'
| 'app'
| 'raw_app'
| 'resource'
| 'variable'
| 'trigger_schedule'
| 'trigger_webhook'
| 'trigger_default_email'
| 'trigger_email'
| 'trigger_http'
| 'trigger_websocket'
| 'trigger_postgres'
| 'trigger_kafka'
| 'trigger_nats'
| 'trigger_mqtt'
| 'trigger_sqs'
| 'trigger_gcp'
| 'trigger_azure'
| 'trigger_poll'
| 'trigger_cli'
| 'trigger_nextcloud'
| 'trigger_google'
| 'trigger_github'
export type UserDraftOptions = {
workspace?: string
}
export type UserDraftUseOptions<V> = UserDraftOptions & {
/**
* Initial value used when localStorage holds no draft for this
* (workspace, itemKind, path). It is *not* eagerly persisted the first
* actual mutation is what writes to localStorage.
*/
defaultValue?: V
}
export type UserDraftListOptions = UserDraftOptions & {
itemKinds?: UserDraftItemKind[]
}
export type UserDraftEntrySource = 'persisted' | 'live' | 'both'
export type UserDraftListEntry<V = unknown> = {
workspace: string
itemKind: UserDraftItemKind
/**
* Storage key path. For new-item editor drafts this can be empty even
* when the draft value already contains its final workspace path.
*/
path: string
storagePath: string
source: UserDraftEntrySource
draftSource?: UserDraftMeta['draftSource']
value: V
}
/**
* A single (kind, path, workspace) tuple that `useMany` should hold a handle
* for. The shape mirrors `use()`'s arguments, just bundled into one object
* so a getter can return a list of them.
*/
export type UserDraftSpec<V> = {
itemKind: UserDraftItemKind
path: string
workspace?: string
defaultValue?: V
}
/**
* Snapshot of the remote item's freshness at the moment the local draft was
* written. Used by editor routes to detect that the remote has moved on
* (someone else deployed, or saved a DB draft) so we can warn the user
* before they push stale changes.
*
* - `remoteRev`: the deployed version's id/hash/timestamp at draft creation.
* - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set
* for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`).
*/
export type UserDraftMeta = {
remoteRev?: string | number
remoteDraftRev?: string | number
draftSource?: 'external'
}
/**
* The shape of what we actually persist. Wrapping the value lets us add
* metadata (timestamps, originating user, schema version, ...) later
* without breaking existing entries.
*
* `lastWrittenAt` is the unix-ms timestamp of the most recent write
* (setter call or deep mutation flush). It's the GC signal
* `gcUserDrafts` sweeps entries that haven't been touched in N days.
* Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`,
* `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing
* (undefined) on entries written before this field was introduced;
* `gcUserDrafts` backfills them on first sighting.
*/
type StoredDraft<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
if (stored === undefined) return undefined
return { ...stored, lastWrittenAt: Date.now() }
}
type DraftState<V> = {
val: StoredDraft<V> | undefined
skipNextWriteOnce(): void
}
type DraftEntry = {
count: number
state: DraftState<unknown>
workspace: string
itemKind: UserDraftItemKind
path: string
/**
* Tears down the `$effect.root` scope that owns the entry's
* `useLocalStorageValue` reactivity its `$state` cell and the persist
* `$effect` deep-mutation loop. Called when the refcount hits 0.
*
* `undefined` only when the test runtime's broken `$effect.root` forced
* us through the fallback path (see `acquireEntry`).
*/
destroyRoot?: () => void
}
const entries = new Map<string, DraftEntry>()
function resolveWorkspace(opts?: UserDraftOptions): string {
const ws = opts?.workspace ?? get(workspaceStore)
if (!ws) {
throw new Error(
'UserDraft: no workspace available (pass opts.workspace or set $workspaceStore)'
)
}
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
if (meta?.draftSource !== undefined) out.draftSource = meta.draftSource
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
if (stored.draftSource !== undefined) meta.draftSource = stored.draftSource
return meta
}
/**
* Compares the rev metadata recorded against the local draft to the current
* backend revs. Returns the staleness cause, or `null` when the local draft
* is still based on the latest backend state we know about.
*
* - Entries with no recorded meta (legacy entries written before this field
* existed) report `null` we can't tell if they're stale, and we'd rather
* trust the local autosave than spam the user with false positives.
* - DB-draft staleness wins over deployed-version staleness: a remote DB
* draft is the more recent state to reconcile against.
* - If a DB draft existed when the local autosave was created but now no
* longer exists on the remote (someone discarded it), we report `version`
* because the deployed version is now the canonical "latest saved".
*/
export type UserDraftStalenessCause = 'draft' | 'version'
export function checkStaleness(
meta: UserDraftMeta,
currentRev: string | number | undefined,
currentDraftRev?: string | number | undefined
): UserDraftStalenessCause | null {
if (meta.remoteRev === undefined && meta.remoteDraftRev === undefined) return null
if (meta.remoteDraftRev !== currentDraftRev) {
return currentDraftRev !== undefined ? 'draft' : 'version'
}
if (currentRev !== undefined && meta.remoteRev !== currentRev) return 'version'
return null
}
/**
* Synchronous localStorage write, bypassing the entry's debounced setter
* and its first-write skip. See `setMeta({ force: true })`.
*/
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
try {
const next = stamp(wrap(value, meta))
if (next === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(next))
}
} catch (e) {
console.error('UserDraft: localStorage write failed', e)
}
}
function readPersisted<V>(key: string): StoredDraft<V> | undefined {
try {
const raw = localStorage.getItem(key)
if (raw == null || raw === 'undefined') return undefined
const parsed = JSON.parse(raw)
// Defensive: ignore pre-wrapping payloads (no `.value`).
if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined
return parsed as StoredDraft<V>
} catch (e) {
console.error('UserDraft: localStorage read failed', e)
return undefined
}
}
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `${workspace}/${itemKind}/${path}`
}
function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `userdraft/w/${workspace}/${itemKind}/${path}`
}
function clone<T>(value: T): T {
const snapshot = $state.snapshot(value)
try {
return structuredClone(snapshot) as T
} catch {
// Live editor values may contain runtime-only fields (functions,
// class instances). Listing should still expose the usable draft
// shape; downstream converters copy only the fields they understand.
return snapshot as T
}
}
function listLocalStorageDrafts(
workspace: string,
itemKinds: Set<UserDraftItemKind> | undefined
): Map<string, UserDraftListEntry> {
const drafts = new Map<string, UserDraftListEntry>()
if (typeof localStorage === 'undefined') return drafts
const prefix = `userdraft/w/${workspace}/`
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key?.startsWith(prefix)) continue
const rest = key.slice(prefix.length)
const separator = rest.indexOf('/')
if (separator === -1) continue
const itemKind = rest.slice(0, separator) as UserDraftItemKind
if (itemKinds && !itemKinds.has(itemKind)) continue
const path = rest.slice(separator + 1)
const stored = readPersisted<unknown>(key)
const value = unwrap(stored)
if (value === undefined) continue
drafts.set(mapKey(workspace, itemKind, path), {
workspace,
itemKind,
path,
storagePath: path,
source: 'persisted',
draftSource: stored?.draftSource,
value: clone(value)
})
}
return drafts
}
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 (no extra persist). Later
* `draft = X` writes preserve the rev metadata.
*/
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void
/**
* Update rev metadata without touching the value. `{ force: true }` also
* persists synchronously use when this may be the entry's first write,
* else the ack is lost on remount.
*/
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
}
/**
* JSON round-trip normalization. localStorage persistence stringifies the
* draft, which silently drops keys whose value is `undefined`, turns `Date`
* into a string, etc. A freshly-built config object (e.g. a trigger editor's
* `getXConfig()`) keeps those `undefined`-valued keys, so a raw
* `deepEqual(persistedDraft, freshConfig)` reports spurious differences
* (`{ a: undefined }` `{}`). Normalize BOTH sides through the same
* round-trip before comparing. Returns the input unchanged if it can't be
* serialized (e.g. a cyclic structure) better a false "differs" than a
* throw inside a load/effect path.
*/
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 persisted local autosave (`localDraft`, as returned by
* `UserDraft.get`) meaningfully differs from the freshly-built
* `currentConfig`. Editor restore guards use this to decide whether to
* overlay the local autosave and toast.
*
* Returns `false` when there is no local draft. Normalizes both sides (see
* `normalizeForCompare`) so a draft that round-trips equal to the deployed
* config e.g. one written by merely opening then closing the editor with
* no edits is correctly treated as "no meaningful draft" instead of
* spuriously triggering a restore on every reopen.
*
* Typed as a guard: a `true` result narrows `localDraft` to non-nullish
* `V`, mirroring the `localCfg && …` narrowing it replaces so call sites
* can pass the draft straight into `loadXConfig(...)` without re-checking.
*/
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))
}
function saveDraft<V>(
itemKind: UserDraftItemKind,
path: string,
value: V,
opts: UserDraftOptions | undefined,
metaPatch: UserDraftMeta,
persistLiveEntry: boolean
): void {
const ws = resolveWorkspace(opts)
const key = localStorageKey(ws, itemKind, path)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Notify observers; preserve existing rev metadata. `untrack`ed
// read — see `set draft` below for why.
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
const meta: UserDraftMeta = { ...extractMeta(current), ...metaPatch }
entry.state.val = wrap(value, meta)
if (persistLiveEntry) {
persistDirect(key, value, meta)
}
return
}
// No live handle: preserve any persisted meta so the staleness
// signal survives a write while the editor is closed.
const existing = readPersisted<unknown>(key)
persistDirect(key, value, { ...extractMeta(existing), ...metaPatch })
}
export const UserDraft = {
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
saveDraft(itemKind, path, value, opts, {}, false)
},
/**
* Persist a draft written by an external actor such as global AI mode.
* Unlike editor baseline hydration, this must be visible immediately to
* draft deployment/listing tools even when a live editor handle is open.
*/
saveExternal<V>(
itemKind: UserDraftItemKind,
path: string,
value: V,
opts?: UserDraftOptions
): void {
saveDraft(itemKind, path, value, opts, { draftSource: 'external' }, true)
},
/**
* Autosave gate: persist `value` only when it differs (after
* `normalizeForCompare`) from the `deployed` baseline; otherwise remove
* any draft. Without this, opening and closing an editor with no edits
* would leave a no-op draft that `has()` / restore guards treat as
* unsaved work.
*/
saveIfChanged<V>(
itemKind: UserDraftItemKind,
path: string,
value: V,
deployed: V | undefined,
opts?: UserDraftOptions
): void {
if (deepEqual(normalizeForCompare(value), normalizeForCompare(deployed))) {
UserDraft.remove(itemKind, path, opts)
} else {
UserDraft.save(itemKind, path, value, opts)
}
},
get<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftOptions
): V | undefined {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
return unwrap(entry.state.val as StoredDraft<V> | undefined)
}
return unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path)))
},
/**
* Update the rev metadata for an entry without touching the value, and
* persist immediately. Used by editor routes that don't hold a live
* handle (apps, raw apps) they read the local draft via `UserDraft.get`
* and the handle is created later inside the child editor.
*
* No-op when the entry has no draft to attach meta to.
*/
saveMeta(
itemKind: UserDraftItemKind,
path: string,
meta: UserDraftMeta,
opts?: UserDraftOptions
): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
if (current === undefined) return
entry.state.val = wrap(current.value, { ...extractMeta(current), ...meta })
}
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
if (existing === undefined) return
persistDirect(localStorageKey(ws, itemKind, path), existing.value, {
...extractMeta(existing),
...meta
})
},
/**
* Read the rev metadata for the entry. Returns an empty object if there
* is no entry. Useful for staleness checks before reading the draft.
*/
getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
return extractMeta(readPersisted<unknown>(localStorageKey(ws, itemKind, path)))
},
/**
* Whether a draft currently exists for (workspace, itemKind, path).
* Falls back to the persisted localStorage entry when no live handle is
* registered. Useful for distinguishing "first visit" from "returning
* visit with unsaved local changes".
*/
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return entry.state.val !== undefined
return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined
},
list(opts?: UserDraftListOptions): UserDraftListEntry[] {
const ws = resolveWorkspace(opts)
const itemKinds = opts?.itemKinds ? new Set(opts.itemKinds) : undefined
const drafts = listLocalStorageDrafts(ws, itemKinds)
for (const [key, entry] of entries) {
if (entry.workspace !== ws) continue
if (itemKinds && !itemKinds.has(entry.itemKind)) continue
const value = unwrap(entry.state.val as StoredDraft<unknown> | undefined)
if (value === undefined) continue
const persisted = drafts.get(key)
drafts.set(key, {
workspace: ws,
itemKind: entry.itemKind,
path: entry.path,
storagePath: entry.path,
source: persisted ? 'both' : 'live',
draftSource: extractMeta(entry.state.val as StoredDraft<unknown> | undefined).draftSource,
value: clone(value)
})
}
return Array.from(drafts.values())
},
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
} catch (e) {
console.error('UserDraft.remove: localStorage remove failed', e)
}
},
/**
* Like `remove`, but also resets any live handle's `draft` to
* `fallback` in-memory (so reactive readers see it immediately) and
* skips re-persisting it, leaving the LS slot empty until the next real
* edit. Pass the deployed baseline as `fallback`.
*/
discard<V>(
itemKind: UserDraftItemKind,
path: string,
fallback: V | undefined,
opts?: UserDraftOptions
): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Arm the skip before the cell write so the setter suppresses
// the persist; the removeItem below actually clears the slot.
entry.state.skipNextWriteOnce()
entry.state.val = wrap(fallback) as StoredDraft<unknown> | undefined
}
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
} catch (e) {
console.error('UserDraft.discard: localStorage remove failed', e)
}
},
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
UserDraft.discard(itemKind, path, undefined, opts)
},
use<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftUseOptions<V>
): UserDraftHandle<V> {
// `use()` is a single-spec wrapper around `useMany`. We untrack the
// getter so that reactive opts (e.g. `$workspaceStore`) are captured
// once at call time — the current `use()` contract is "the handle
// stays bound to this workspace until the component unmounts." Use
// `useMany` directly if you want spec changes to release/acquire
// entries as you go.
const handles = UserDraft.useMany<V>(() =>
untrack(() => [
{
itemKind,
path,
workspace: opts?.workspace,
defaultValue: opts?.defaultValue
}
])
)
return handles[0]
},
useMany<V = unknown>(getSpecs: () => UserDraftSpec<V>[]): UserDraftHandle<V>[] {
// Reactive handles array, reconciled against the latest `getSpecs()`
// output. Indices line up with the spec array. Handles for the same
// (workspace, kind, path) tuple are reused across reconciles so
// callers can capture a reference and keep it alive — only the
// underlying entry's refcount moves.
const handles = $state<UserDraftHandle<V>[]>([])
const acquired = new Set<string>()
const handleCache = new Map<string, UserDraftHandle<V>>()
function reconcile() {
const specs = getSpecs()
const seen = new Set<string>()
const next: UserDraftHandle<V>[] = []
for (const spec of specs) {
const ws = spec.workspace ?? resolveWorkspace()
const mk = mapKey(ws, spec.itemKind, spec.path)
seen.add(mk)
if (!acquired.has(mk)) {
acquireEntry(ws, spec.itemKind, spec.path, spec.defaultValue)
acquired.add(mk)
}
let handle = handleCache.get(mk)
if (!handle) {
handle = makeHandle<V>(ws, spec.itemKind, spec.path)
handleCache.set(mk, handle)
}
next.push(handle)
}
for (const mk of [...acquired]) {
if (!seen.has(mk)) {
releaseEntry(mk)
acquired.delete(mk)
handleCache.delete(mk)
}
}
// Skip no-op mutations (handles are cached by mapKey, so an
// unchanged spec set yields reference-equal arrays). `untrack` so
// this effect doesn't subscribe to its own `handles` write —
// otherwise it self-loops (`effect_update_depth_exceeded`).
// Downstream notification still propagates.
untrack(() => {
const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i])
if (!unchanged) handles.splice(0, handles.length, ...next)
})
}
// Synchronous initial reconcile so single-spec callers (`use()`) get a
// populated `handles[0]` before the function returns. Reactive reads
// inside `getSpecs()` here are intentionally not tracked — the
// `$effect` below picks up any subsequent dependency changes.
untrack(reconcile)
$effect(reconcile)
onDestroy(() => {
for (const mk of acquired) releaseEntry(mk)
acquired.clear()
handleCache.clear()
})
return handles
}
}
function acquireEntry(
workspace: string,
itemKind: UserDraftItemKind,
path: string,
defaultValue: unknown
): void {
const mk = mapKey(workspace, itemKind, path)
const existing = entries.get(mk)
if (existing) {
existing.count++
return
}
// `useLocalStorageValue`'s internal persist `$effect` would otherwise
// parent to `useMany`'s reconcile effect and be torn down on the next
// reconcile. `$effect.root` gives the entry its own scope, disposed only
// by `releaseEntry`.
const useLocalStorageOptions = {
// First value is the baseline (don't persist it); coalesce edits.
saveInitialValue: false,
debounce: 500,
// Stamp `lastWrittenAt` at persist time so deep mutations also bump
// the GC clock (the setter doesn't re-run for those).
transformBeforePersist: stamp<unknown>
} as const
let stateRef: DraftState<unknown> | undefined
const destroyRoot = $effect.root(() => {
stateRef = useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(workspace, itemKind, path),
wrap(defaultValue),
undefined,
useLocalStorageOptions
)
})
if (stateRef) {
entries.set(mk, {
count: 1,
state: stateRef,
workspace,
itemKind,
path,
destroyRoot
})
return
}
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
// invoked. Unreachable in production (Svelte runs it synchronously).
const state = useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(workspace, itemKind, path),
wrap(defaultValue),
undefined,
useLocalStorageOptions
)
entries.set(mk, { count: 1, state, workspace, itemKind, path })
}
function releaseEntry(mk: string): void {
const entry = entries.get(mk)
if (!entry) return
entry.count--
if (entry.count <= 0) {
entry.destroyRoot?.()
entries.delete(mk)
}
}
function makeHandle<V>(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): UserDraftHandle<V> {
// The handle reads `entries.get(mk)` on every access. The entry it points
// at is stable as long as the refcount stays > 0 (which `useMany` keeps
// the case for as long as a spec references it). If the refcount drops to
// 0 and the entry is destroyed, reads return `undefined` rather than
// throwing — the consumer should already have been torn down by that point.
const mk = mapKey(workspace, itemKind, path)
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
return {
get draft(): V | undefined {
return unwrap(stateOf()?.val as StoredDraft<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 {
// Read under `untrack` for the same reason as `set draft` above —
// avoid making any surrounding effect re-fire on the write below.
const state = stateOf()
if (!state) return
const current = untrack(() => state.val as StoredDraft<V> | undefined)
if (current === undefined) return
state.val = wrap(current.value, { ...extractMeta(current), ...meta })
if (opts?.force) {
persistDirect(localStorageKey(workspace, itemKind, path), current.value, {
...extractMeta(current),
...meta
})
}
}
}
}
/**
* Default GC retention window: 30 days. Entries that haven't been touched
* (no setter call, no deep-mutation persist) for this long are swept on
* the next `gcUserDrafts` invocation.
*/
export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
/**
* Sweep stale UserDraft entries from localStorage. Walks every
* `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes
* any entry older than `maxAgeMs`.
*
* Entries written before `lastWrittenAt` was introduced lack the field;
* we backfill them to `now()` on first sighting so they participate in
* the next sweep cycle rather than getting wiped immediately.
*
* Safe to call on every load and on a timer (e.g. every 30 min) live
* entries get their stamp refreshed on every persist, so the sweep only
* touches truly stale records.
*/
export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void {
if (typeof localStorage === 'undefined') return
const now = Date.now()
const cutoff = now - maxAgeMs
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k != null && k.startsWith('userdraft/w/')) keys.push(k)
}
for (const key of keys) {
try {
const raw = localStorage.getItem(key)
if (raw == null) continue
const parsed = JSON.parse(raw)
if (parsed == null || typeof parsed !== 'object') continue
if (typeof parsed.lastWrittenAt !== 'number') {
// Pre-GC-feature entry. Backfill so the next sweep can decide.
parsed.lastWrittenAt = now
localStorage.setItem(key, JSON.stringify(parsed))
continue
}
if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key)
} catch (e) {
console.error('UserDraft GC: failed to inspect', key, e)
}
}
}
/** Test-only: clear all in-memory entries. */
export function __resetUserDraftForTesting(): void {
entries.clear()
}
+811
View File
@@ -0,0 +1,811 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Capture onDestroy callbacks so we can simulate component teardown without
// a real component context.
const onDestroyCallbacks: Array<() => void> = []
vi.mock('svelte', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
onDestroy: (fn: () => void) => {
onDestroyCallbacks.push(fn)
}
}
})
// Imported AFTER vi.mock so the module sees the mocked onDestroy.
const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } =
await import('./userDraft.svelte')
const { workspaceStore } = await import('./stores')
function flushDestroyCallbacks(): void {
const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length)
for (const cb of callbacks) cb()
}
// UserDraft.use debounces localStorage writes by 500 ms via
// useLocalStorageValue. Tests assert localStorage state synchronously after
// writes, so we use fake timers and call this helper to fast-forward past
// the debounce window before each assertion.
function flushPersist(): void {
vi.runAllTimers()
}
// Helper: localStorage payloads are always wrapped as { value: <draft> } so
// future metadata fields can be added without breaking existing entries.
function wrapped<V>(value: V, meta: Record<string, unknown> = {}): string {
return JSON.stringify({ value, ...meta })
}
// Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so
// assertions can stay focused on value + rev metadata. Real entries always
// carry `lastWrittenAt` once written; the GC tests below assert on it
// directly via `localStorage.getItem`.
function storedShape(key: string): string | null {
const raw = localStorage.getItem(key)
if (raw == null) return null
const parsed = JSON.parse(raw)
delete parsed.lastWrittenAt
return JSON.stringify(parsed)
}
beforeEach(() => {
__resetUserDraftForTesting()
onDestroyCallbacks.length = 0
localStorage.clear()
workspaceStore.set('test_ws')
vi.useFakeTimers()
})
describe('UserDraft.save / get / remove (no observers)', () => {
it('save writes a wrapped { value } payload under the workspace-scoped key', () => {
UserDraft.save('flow', 'u/me/myflow', { hello: 'world' })
expect(storedShape('userdraft/w/test_ws/flow/u/me/myflow')).toBe(wrapped({ hello: 'world' }))
})
it('get reads from a wrapped localStorage payload when no observer is registered', () => {
localStorage.setItem('userdraft/w/test_ws/script/u/me/script1', wrapped('code'))
expect(UserDraft.get('script', 'u/me/script1')).toBe('code')
})
it('get returns undefined when nothing is stored', () => {
expect(UserDraft.get('flow', 'u/me/missing')).toBeUndefined()
})
it('get returns undefined when the stored payload is malformed', () => {
localStorage.setItem('userdraft/w/test_ws/flow/u/me/bad', 'not-json')
expect(UserDraft.get('flow', 'u/me/bad')).toBeUndefined()
})
it('get returns undefined when the stored payload is unwrapped (pre-migration entry)', () => {
// Drafts written before the wrapping was introduced look like the raw
// value rather than { value: ... }. They must be ignored rather than
// surface as undefined-shaped drafts.
localStorage.setItem('userdraft/w/test_ws/flow/u/me/raw', JSON.stringify({ hello: 'world' }))
expect(UserDraft.get('flow', 'u/me/raw')).toBeUndefined()
expect(UserDraft.has('flow', 'u/me/raw')).toBe(false)
})
it('remove clears the localStorage entry', () => {
UserDraft.save('app', 'u/me/app1', { grid: [] })
expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).not.toBeNull()
UserDraft.remove('app', 'u/me/app1')
expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).toBeNull()
})
it('uses the workspace from opts when provided', () => {
UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' })
expect(storedShape('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1))
// Default workspace key must remain empty.
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull()
})
it('supports trigger kinds as item kinds', () => {
UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] })
expect(storedShape('userdraft/w/test_ws/trigger_kafka/u/me/topic1')).toBe(
wrapped({ brokers: ['localhost:9092'] })
)
})
it('throws when neither opts.workspace nor $workspaceStore is set', () => {
workspaceStore.set(undefined)
expect(() => UserDraft.save('flow', 'u/me/x', 1)).toThrow(/no workspace/)
})
})
describe('UserDraft.use() — observer sync', () => {
it('loads the existing localStorage value on first use', () => {
localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded'))
const handle = UserDraft.use<string>('flow', 'u/me/loaded')
expect(handle.draft).toBe('preloaded')
})
it('two handles on the same key share the same underlying state', () => {
const a = UserDraft.use<number>('flow', 'u/me/shared')
const b = UserDraft.use<number>('flow', 'u/me/shared')
a.draft = 42
expect(b.draft).toBe(42)
b.draft = 99
expect(a.draft).toBe(99)
})
it('save() propagates to live use() handles (in-memory)', () => {
const handle = UserDraft.use<number>('flow', 'u/me/observed')
expect(handle.draft).toBeUndefined()
// First write through a live entry is treated as the "initial value"
// (saveInitialValue=false) and is NOT persisted — observers still see it.
UserDraft.save('flow', 'u/me/observed', 7)
expect(handle.draft).toBe(7)
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBeNull()
// Subsequent writes persist.
UserDraft.save('flow', 'u/me/observed', 9)
expect(handle.draft).toBe(9)
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
})
it('saveExternal() persists through live use() handles and marks the draft source', () => {
const handle = UserDraft.use<number>('flow', 'u/me/external')
expect(handle.draft).toBeUndefined()
UserDraft.saveExternal('flow', 'u/me/external', 7)
expect(handle.draft).toBe(7)
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/external')).toBe(
wrapped(7, { draftSource: 'external' })
)
})
it('remove() clears localStorage without touching the in-memory handle', () => {
// Seed localStorage so the live handle initialises from it.
localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1))
const handle = UserDraft.use<number>('flow', 'u/me/removed')
expect(handle.draft).toBe(1)
UserDraft.remove('flow', 'u/me/removed')
// Live handle keeps its current value — remove() only wipes the
// persisted side. This is what lets callers run UserDraft.remove
// during navigation without flickering the editor UI.
expect(handle.draft).toBe(1)
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull()
})
it('discard() clears LS, resets the handle to the fallback, and does NOT re-persist', () => {
// Seed: handle holds a divergent local autosave.
localStorage.setItem('userdraft/w/test_ws/flow/u/me/discard', wrapped('local-edit'))
const handle = UserDraft.use<string>('flow', 'u/me/discard')
expect(handle.draft).toBe('local-edit')
// Reset to a known backend baseline.
UserDraft.discard('flow', 'u/me/discard', 'backend-baseline')
flushPersist()
// In-memory handle reflects the fallback immediately.
expect(handle.draft).toBe('backend-baseline')
// LS is cleared and stays cleared — the fallback must NOT round-trip
// back into storage (that would make the next reload "restore" the
// fallback as if it were a real autosave).
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/discard')).toBeNull()
})
it('discard() with undefined fallback clears both LS and in-memory state', () => {
localStorage.setItem('userdraft/w/test_ws/flow/u/me/wipe', wrapped('local-edit'))
const handle = UserDraft.use<string>('flow', 'u/me/wipe')
expect(handle.draft).toBe('local-edit')
UserDraft.discard('flow', 'u/me/wipe', undefined)
flushPersist()
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull()
})
it('clear() clears both localStorage and the live handle', () => {
localStorage.setItem('userdraft/w/test_ws/flow/u/me/clear-live', wrapped('local-edit'))
const handle = UserDraft.use<string>('flow', 'u/me/clear-live')
expect(handle.draft).toBe('local-edit')
UserDraft.clear('flow', 'u/me/clear-live')
flushPersist()
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear-live')).toBeNull()
})
it('the second write through the handle setter persists to localStorage', () => {
const handle = UserDraft.use<string>('flow', 'u/me/setter')
// First write is the baseline — not persisted.
handle.draft = 'initial'
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBeNull()
// Second (and onwards) persists.
handle.draft = 'persisted'
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted'))
})
it('setting handle.draft = undefined after edits removes the localStorage entry', () => {
const handle = UserDraft.use<string>('flow', 'u/me/clear')
handle.draft = 'initial' // baseline, not persisted
handle.draft = 'edited' // persisted
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).not.toBeNull()
handle.draft = undefined
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).toBeNull()
expect(handle.draft).toBeUndefined()
})
it('two handles in different workspaces are isolated', () => {
const a = UserDraft.use<number>('flow', 'u/me/iso', { workspace: 'ws_a' })
const b = UserDraft.use<number>('flow', 'u/me/iso', { workspace: 'ws_b' })
a.draft = 1
b.draft = 2
expect(a.draft).toBe(1)
expect(b.draft).toBe(2)
})
it('save() falls back to localStorage when no handle is registered', () => {
UserDraft.save('flow', 'u/me/noobs', 'fallback')
// First use() afterwards loads the persisted value.
const handle = UserDraft.use<string>('flow', 'u/me/noobs')
expect(handle.draft).toBe('fallback')
})
})
describe('UserDraft.use() — defaultValue', () => {
it('returns defaultValue when localStorage has no entry', () => {
const handle = UserDraft.use<string>('flow', 'u/me/withdefault', { defaultValue: 'fallback' })
expect(handle.draft).toBe('fallback')
})
it('does not persist the defaultValue on first read', () => {
UserDraft.use<string>('flow', 'u/me/lazyDefault', { defaultValue: 'fallback' })
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/lazyDefault')).toBeNull()
})
it('localStorage value wins over defaultValue', () => {
localStorage.setItem('userdraft/w/test_ws/flow/u/me/overridden', wrapped('persisted'))
const handle = UserDraft.use<string>('flow', 'u/me/overridden', {
defaultValue: 'fallback'
})
expect(handle.draft).toBe('persisted')
})
it('second write through the setter persists even though defaultValue was set', () => {
const handle = UserDraft.use<string>('flow', 'u/me/writeDefault', {
defaultValue: 'fallback'
})
// First write is the initial-value baseline.
handle.draft = 'initial'
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBeNull()
handle.draft = 'modified'
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(wrapped('modified'))
})
})
describe('UserDraft.list()', () => {
it('lists persisted drafts for the selected workspace and item kinds', () => {
UserDraft.save('script', 'u/me/script1', { content: 'code' })
UserDraft.save('flow', 'u/me/flow1', { modules: [] })
UserDraft.save('resource', 'u/me/resource1', { path: 'u/me/resource1' })
UserDraft.save('script', 'u/other/script2', { content: 'other' }, { workspace: 'other_ws' })
localStorage.setItem('userdraft/w/test_ws/script/u/me/bad', 'not-json')
expect(UserDraft.list({ itemKinds: ['script', 'flow'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'u/me/script1',
storagePath: 'u/me/script1',
source: 'persisted',
draftSource: undefined,
value: { content: 'code' }
},
{
workspace: 'test_ws',
itemKind: 'flow',
path: 'u/me/flow1',
storagePath: 'u/me/flow1',
source: 'persisted',
draftSource: undefined,
value: { modules: [] }
}
])
})
it('exposes draftSource for externally saved drafts', () => {
UserDraft.saveExternal('script', 'u/me/script1', { content: 'code' })
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'u/me/script1',
storagePath: 'u/me/script1',
source: 'persisted',
draftSource: 'external',
value: { content: 'code' }
}
])
})
it('prefers live handle state over a stale localStorage entry', () => {
const handle = UserDraft.use<{ source: string }>('script', 'u/me/live')
handle.draft = { source: 'live' }
localStorage.setItem('userdraft/w/test_ws/script/u/me/live', wrapped({ source: 'persisted' }))
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'u/me/live',
storagePath: 'u/me/live',
source: 'both',
draftSource: undefined,
value: { source: 'live' }
}
])
})
})
describe('UserDraft — empty path (new-item drafts persist across reloads)', () => {
it('use() with empty path persists subsequent edits to localStorage', () => {
const handle = UserDraft.use<number>('flow', '', { defaultValue: 0 })
// First write under saveInitialValue=false counts as the baseline and
// is skipped — only the user's subsequent edits persist.
handle.draft = 99
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
handle.draft = 100
flushPersist()
// The "+ Flow / + Script / …" buttons are expected to call
// `UserDraft.remove(kind, '')` to wipe before navigating; an
// unguarded /add reload therefore restores the previous session.
expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(100))
})
it('two handles with empty path share state per workspace', () => {
const a = UserDraft.use<number>('flow', '')
const b = UserDraft.use<number>('flow', '')
a.draft = 1
expect(b.draft).toBe(1)
b.draft = 2
expect(a.draft).toBe(2)
})
it('save() with empty path writes to localStorage when no handle is live', () => {
UserDraft.save('flow', '', 5)
expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(5))
})
it('get() with empty path falls back to localStorage when no handle is live', () => {
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(11))
expect(UserDraft.get('flow', '')).toBe(11)
})
it('remove() with empty path clears localStorage', () => {
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(1))
UserDraft.remove('flow', '')
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
})
})
describe('UserDraft — rev metadata for staleness checks', () => {
it('setDraftAndMeta atomically stores value + rev, and the first write is still skipped', () => {
const handle = UserDraft.use<string>('flow', 'u/me/atomic')
// Single atomic write — under saveInitialValue=false this counts as the
// initial baseline and shouldn't hit localStorage yet.
handle.setDraftAndMeta('backendValue', {
remoteRev: 42,
remoteDraftRev: '2026-01-01T00:00:00Z'
})
expect(handle.draft).toBe('backendValue')
expect(handle.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-01-01T00:00:00Z' })
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')).toBeNull()
// A subsequent user edit persists *with* the rev metadata.
handle.draft = 'userEdit'
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/atomic')).toBe(
JSON.stringify({
value: 'userEdit',
remoteRev: 42,
remoteDraftRev: '2026-01-01T00:00:00Z'
})
)
})
it('setMeta updates only the rev fields, preserving the value', () => {
const handle = UserDraft.use<string>('flow', 'u/me/setmeta')
handle.setDraftAndMeta('initial', { remoteRev: 1 }) // baseline, not persisted
handle.draft = 'edited' // persisted with remoteRev: 1
handle.setMeta({ remoteRev: 2 })
expect(handle.draft).toBe('edited')
expect(handle.meta).toEqual({ remoteRev: 2 })
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/setmeta')).toBe(
JSON.stringify({ value: 'edited', remoteRev: 2 })
)
})
it('handle.draft setter preserves rev metadata across user edits', () => {
const handle = UserDraft.use<{ count: number }>('flow', 'u/me/preserve')
handle.setDraftAndMeta({ count: 0 }, { remoteRev: 'v1' })
handle.draft = { count: 1 } // first edit, persisted
handle.draft = { count: 2 } // another edit
expect(handle.meta).toEqual({ remoteRev: 'v1' })
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/preserve')).toBe(
JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' })
)
})
it('UserDraft.getMeta reads from localStorage when no live handle exists', () => {
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/getmeta',
JSON.stringify({ value: 'x', remoteRev: 7, remoteDraftRev: '2026-01-02' })
)
expect(UserDraft.getMeta('flow', 'u/me/getmeta')).toEqual({
remoteRev: 7,
remoteDraftRev: '2026-01-02'
})
})
it('UserDraft.getMeta returns empty object when there is no entry', () => {
expect(UserDraft.getMeta('flow', 'u/me/none')).toEqual({})
})
it('UserDraft.save preserves persisted rev metadata when no live handle exists', () => {
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/savepreserve',
JSON.stringify({ value: 'old', remoteRev: 5 })
)
UserDraft.save('flow', 'u/me/savepreserve', 'new')
expect(storedShape('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe(
JSON.stringify({ value: 'new', remoteRev: 5 })
)
})
it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => {
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/legacy',
JSON.stringify({ value: 'no-rev' })
)
const handle = UserDraft.use<string>('flow', 'u/me/legacy')
expect(handle.draft).toBe('no-rev')
expect(handle.meta).toEqual({})
})
it('setMeta({ force: true }) persists immediately, bypassing the first-write skip', () => {
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/forceack',
JSON.stringify({ value: 'edited', remoteRev: 'v1' })
)
const handle = UserDraft.use<string>('flow', 'u/me/forceack')
// Without force, this is the entry's first state mutation and gets
// swallowed by saveInitialValue=false — localStorage would still
// hold the old remoteRev.
handle.setMeta({ remoteRev: 'v2' }, { force: true })
expect(handle.meta).toEqual({ remoteRev: 'v2' })
expect(storedShape('userdraft/w/test_ws/flow/u/me/forceack')).toBe(
JSON.stringify({ value: 'edited', remoteRev: 'v2' })
)
})
})
describe('checkStaleness', () => {
let checkStaleness: (
meta: { remoteRev?: string | number; remoteDraftRev?: string | number },
currentRev: string | number | undefined,
currentDraftRev?: string | number | undefined
) => 'draft' | 'version' | null
beforeEach(async () => {
// Re-import to dodge ESM caching surprises across test files.
;({ checkStaleness } = await import('./userDraft.svelte'))
})
it('returns null for legacy entries with no recorded rev', () => {
expect(checkStaleness({}, 'h1', '2026-01-01')).toBeNull()
})
it('returns null when meta matches current revs exactly', () => {
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd1')).toBeNull()
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', undefined)).toBeNull()
})
it('returns "draft" when a newer DB draft was pushed on the remote', () => {
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd2')).toBe('draft')
})
it('returns "draft" when the remote gained a DB draft that we didn\'t baseline against', () => {
expect(checkStaleness({ remoteRev: 'h1' }, 'h1', 'd1')).toBe('draft')
})
it('returns "version" when the deployed rev moved and draft revs match', () => {
expect(checkStaleness({ remoteRev: 'h1' }, 'h2', undefined)).toBe('version')
})
it('returns "version" when the baseline draft was deleted on the remote (no current draft)', () => {
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', undefined)).toBe(
'version'
)
})
it('prefers "draft" over "version" when both have changed', () => {
expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h2', 'd2')).toBe('draft')
})
})
describe('UserDraft.use() — reference counting & cleanup', () => {
it('destroys the entry when the last handle is released', () => {
// First handle acquires the entry.
const a = UserDraft.use<number>('flow', 'u/me/ref')
a.draft = 1 // baseline write — not persisted
// Second handle increments the count.
const b = UserDraft.use<number>('flow', 'u/me/ref')
expect(b.draft).toBe(1)
// onDestroy for both handles got registered.
expect(onDestroyCallbacks.length).toBe(2)
// Releasing one handle keeps the entry alive — save() still updates handle a.
const firstCb = onDestroyCallbacks.shift()!
firstCb()
UserDraft.save('flow', 'u/me/ref', 2)
expect(a.draft).toBe(2)
// Now persisted (second write after the baseline).
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
// Releasing the second handle drops the entry; subsequent save()
// must go straight to localStorage rather than mutating in-memory
// state (which no longer exists).
const secondCb = onDestroyCallbacks.shift()!
secondCb()
UserDraft.save('flow', 'u/me/ref', 3)
// UserDraft.save without a live entry writes synchronously.
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3))
})
it('a fresh use() after cleanup re-reads the latest persisted value', () => {
const a = UserDraft.use<string>('flow', 'u/me/cycle')
a.draft = 'initial' // baseline — not persisted
a.draft = 'edited' // persisted (after debounce)
flushPersist()
flushDestroyCallbacks()
// After all handles release, a brand-new use() must pick up the
// value persisted to localStorage from the previous round.
const b = UserDraft.use<string>('flow', 'u/me/cycle')
expect(b.draft).toBe('edited')
})
it('coalesces a typing storm into a single localStorage write per 500 ms window', () => {
const handle = UserDraft.use<string>('flow', 'u/me/debounce')
handle.draft = 'baseline' // first write — skipped under saveInitialValue=false
// Three quick edits inside the 500 ms window: in-memory updates every
// time, but localStorage stays untouched until the timer fires.
handle.draft = 'one'
vi.advanceTimersByTime(100)
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
handle.draft = 'two'
vi.advanceTimersByTime(100)
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
handle.draft = 'three'
expect(handle.draft).toBe('three')
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull()
// After the window elapses, only the latest value lands.
vi.advanceTimersByTime(500)
expect(storedShape('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three'))
})
})
describe('UserDraft.useMany()', () => {
it('acquires one handle per spec in the synchronous initial reconcile', () => {
// `useMany`'s sync reconcile populates handles[0..] before returning,
// so callers (and `use()`'s 1-len wrapper) can use them immediately
// without waiting for an `$effect` tick.
const handles = UserDraft.useMany<number>(() => [
{ itemKind: 'flow', path: 'u/me/many', workspace: 'a' },
{ itemKind: 'flow', path: 'u/me/many', workspace: 'b' }
])
expect(handles.length).toBe(2)
// Each spec gets its own entry in the workspace-keyed store.
handles[0].draft = 0 // baseline
handles[0].draft = 1 // persisted
handles[1].draft = 0
handles[1].draft = 9
flushPersist()
expect(storedShape('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1))
expect(storedShape('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9))
// One component-level onDestroy releases every acquired entry.
expect(onDestroyCallbacks.length).toBe(1)
})
})
describe('gcUserDrafts', () => {
let gcUserDrafts: (maxAgeMs?: number) => void
let USER_DRAFT_GC_MAX_AGE_MS: number
const DAY = 24 * 60 * 60 * 1000
beforeEach(async () => {
;({ gcUserDrafts, USER_DRAFT_GC_MAX_AGE_MS } = await import('./userDraft.svelte'))
})
it('sweeps entries whose lastWrittenAt is older than the cutoff', () => {
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
const old = Date.now() - 31 * DAY
const fresh = Date.now() - 1 * DAY
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/old',
JSON.stringify({ value: 1, lastWrittenAt: old })
)
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/fresh',
JSON.stringify({ value: 2, lastWrittenAt: fresh })
)
// Unrelated keys are left alone.
localStorage.setItem('some_other_key', 'unrelated')
gcUserDrafts()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/old')).toBeNull()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/fresh')).not.toBeNull()
expect(localStorage.getItem('some_other_key')).toBe('unrelated')
})
it('backfills lastWrittenAt on entries lacking it, instead of sweeping them immediately', () => {
// Pre-GC-feature entry (legacy migration output, or just an old entry
// from earlier in this PR's lifecycle): no `lastWrittenAt`. First GC
// pass should stamp it as "now" rather than wipe it on sight.
localStorage.setItem('userdraft/w/test_ws/flow/u/me/legacy', JSON.stringify({ value: 'data' }))
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
gcUserDrafts()
const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/legacy')
expect(raw).not.toBeNull()
const parsed = JSON.parse(raw!)
expect(parsed.lastWrittenAt).toBe(Date.now())
expect(parsed.value).toBe('data')
})
it('exposes a 30-day default retention window', () => {
expect(USER_DRAFT_GC_MAX_AGE_MS).toBe(30 * 24 * 60 * 60 * 1000)
})
it('respects a custom maxAgeMs', () => {
vi.setSystemTime(new Date('2026-06-01T00:00:00Z'))
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/two_hours_ago',
JSON.stringify({ value: 1, lastWrittenAt: Date.now() - 2 * 60 * 60 * 1000 })
)
gcUserDrafts(60 * 60 * 1000) // 1h cutoff
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull()
})
})
describe('normalizeForCompare', () => {
it('returns undefined for undefined input', () => {
expect(normalizeForCompare(undefined)).toBeUndefined()
})
it('drops keys whose value is undefined (mirrors JSON.stringify persistence)', () => {
const out = normalizeForCompare({ a: 1, b: undefined, c: { d: undefined, e: 2 } })
expect(out).toEqual({ a: 1, c: { e: 2 } })
expect(Object.keys(out as object)).not.toContain('b')
expect(Object.keys((out as any).c)).not.toContain('d')
})
it('falls back to the original value when not serializable (cyclic)', () => {
const cyclic: any = { a: 1 }
cyclic.self = cyclic
expect(normalizeForCompare(cyclic)).toBe(cyclic)
})
})
describe('localDraftDiffers', () => {
it('returns false when there is no local draft', () => {
expect(localDraftDiffers(undefined, { a: 1 })).toBe(false)
expect(localDraftDiffers(null, { a: 1 })).toBe(false)
})
it('treats a draft that round-trips equal to the config as NOT differing', () => {
// The Schedule bug: getXCfg() emits conditionally-undefined keys, but
// the persisted draft went through JSON.stringify which dropped them.
const freshCfg = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
const persisted = JSON.parse(JSON.stringify(freshCfg)) // { path, schedule }
expect(localDraftDiffers(persisted, freshCfg)).toBe(false)
})
it('returns true for a genuine difference', () => {
expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true)
expect(localDraftDiffers({ a: 1, extra: 'x' }, { a: 1 })).toBe(true)
})
})
describe('UserDraft.saveIfChanged', () => {
const KEY = 'userdraft/w/test_ws/trigger_schedule/u/me/s'
it('does not persist a draft equal to the deployed baseline', () => {
const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
// value is the post-load reactive cfg — same shape, undefined keys present
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed)
expect(localStorage.getItem(KEY)).toBeNull()
})
it('treats a value that round-trips equal to deployed as unchanged', () => {
const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined }
const value = JSON.parse(JSON.stringify(deployed)) // { path, schedule }
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed)
expect(localStorage.getItem(KEY)).toBeNull()
})
it('persists when the value differs from the deployed baseline', () => {
const deployed = { path: 'u/me/s', schedule: '0 0 * * *' }
const value = { path: 'u/me/s', schedule: '5 0 * * *' }
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed)
expect(storedShape(KEY)).toBe(wrapped(value))
})
it('removes a pre-existing draft once the value reverts to deployed', () => {
const deployed = { path: 'u/me/s', schedule: '0 0 * * *' }
UserDraft.save('trigger_schedule', 'u/me/s', { path: 'u/me/s', schedule: '5 0 * * *' })
expect(localStorage.getItem(KEY)).not.toBeNull()
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed)
expect(localStorage.getItem(KEY)).toBeNull()
})
it('persists when there is no deployed baseline (undefined)', () => {
const value = { path: 'u/me/s', schedule: '0 0 * * *' }
UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, undefined)
expect(storedShape(KEY)).toBe(wrapped(value))
})
})
@@ -0,0 +1,223 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
migrateLegacyUserDrafts,
__resetUserDraftLegacyMigrationForTesting
} from './userDraftLegacyMigration'
function encodeLegacy(value: unknown): string {
return btoa(encodeURIComponent(JSON.stringify(value)))
}
function wrapped<V>(value: V): string {
return JSON.stringify({ value })
}
// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions
// can match the `{ value }` shape regardless of when the migration ran.
function storedShape(key: string): string | null {
const raw = localStorage.getItem(key)
if (raw == null) return null
const parsed = JSON.parse(raw)
delete parsed.lastWrittenAt
return JSON.stringify(parsed)
}
beforeEach(() => {
localStorage.clear()
__resetUserDraftLegacyMigrationForTesting()
})
describe('migrateLegacyUserDrafts', () => {
it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => {
// Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`,
// i.e. the inner App value, not the wrapping AppWithLastVersion.
const legacyApp = {
grid: [],
fullscreen: false,
theme: undefined,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-u/me/dashboard')).toBeNull()
expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp))
})
it('migrates a legacy empty-path app draft (the `app` literal key)', () => {
const legacyApp = {
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
localStorage.setItem('app', encodeLegacy(legacyApp))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app')).toBeNull()
expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp))
})
it('migrates a legacy flow draft and strips the view-state envelope', () => {
const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' }
const legacyBundle = {
flow,
path: 'u/me/myflow',
selectedId: 'settings',
draft_triggers: [{ id: 't1' }],
selected_trigger: null,
loadedFromHistory: undefined
}
localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('flow-u/me/myflow')).toBeNull()
// Only the inner Flow survives; the view-state envelope is dropped.
expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow))
})
it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => {
const legacy = {
files: { 'index.tsx': 'export default () => null' },
runnables: {},
data: { tables: [] }
}
localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('rawapp-u/me/site')).toBeNull()
expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe(
wrapped({ ...legacy, summary: '' })
)
})
it('preserves an existing new-format entry instead of overwriting it', () => {
// Old and new both exist for the same item — the new one is presumed
// fresher.
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
const existingNew = wrapped({ value: 'new' })
localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-u/me/dash')).toBeNull()
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew)
})
it('is idempotent — the second invocation is a no-op', () => {
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull()
// Drop the migrated entry to detect any re-migration attempt.
localStorage.removeItem('userdraft/w/main/app/u/me/dash')
// Drop the source too, so re-running couldn't even find a source.
// (The sentinel alone should be enough; this just clarifies the intent.)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
})
it('skips entirely when no workspace is available', () => {
localStorage.setItem(
'app-u/me/dash',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
migrateLegacyUserDrafts('')
expect(localStorage.getItem('app-u/me/dash')).not.toBeNull()
})
it('handles malformed legacy payloads without throwing', () => {
localStorage.setItem('app-u/me/garbled', 'not-base64!!!')
expect(() => migrateLegacyUserDrafts('main')).not.toThrow()
// Migration didn't migrate, didn't crash — leaves the entry alone.
expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!')
})
it('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => {
// A future feature or neighbouring code might pick a key like
// `app-recent` for its own purposes. The path doesn't look like a
// Windmill item path, so the migration must skip it.
localStorage.setItem('app-recent', 'whatever')
localStorage.setItem('app-some_other_app', 'whatever')
// `flow-u/me/foo` matches the shape and would be migrated, but the
// payload also needs to look like a Windmill draft (asserted below).
localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } }))
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-recent')).toBe('whatever')
expect(localStorage.getItem('app-some_other_app')).toBe('whatever')
expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull()
})
it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => {
// `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON,
// but none of the App-shape fields (grid/fullscreen/theme/
// unusedInlineScripts/hiddenInlineScripts) are present. Treat it as
// unrelated and leave it untouched.
const unrelated = encodeLegacy({ random: 'data', count: 7 })
localStorage.setItem('app-u/me/dash', unrelated)
const unrelatedFlow = encodeLegacy({ stepsState: {} })
localStorage.setItem('flow-u/me/bar', unrelatedFlow)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated)
expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull()
expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow)
expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull()
})
it('migrates multiple legacy entries in a single invocation', () => {
localStorage.setItem(
'app-u/me/a',
encodeLegacy({
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
})
)
localStorage.setItem(
'flow-u/me/b',
encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } })
)
localStorage.setItem(
'rawapp-u/me/c',
encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } })
)
migrateLegacyUserDrafts('main')
expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull()
expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull()
expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull()
})
})
@@ -0,0 +1,192 @@
/**
* One-off migration from the pre-UserDraft localStorage autosave entries to
* the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format.
*
* Legacy keys (global, not workspace-scoped assumed to belong to the user's
* current workspace at migration time):
*
* `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })`
* `app` / `app-{path}` base64 of `encodeState(App)`
* `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })`
*
* Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing
* `JSON.stringify({ value: <transformed legacy value> })`.
*
* Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so
* subsequent invocations are no-ops. Existing new-format entries are never
* overwritten when both an old and a new entry exist for the same item, the
* old one is simply dropped on the assumption that the new entry is the more
* recent edit.
*
* This file is intentionally standalone it does not import from
* `userDraft.svelte.ts` so the new code stays uncluttered by the legacy
* decoders.
*/
const MIGRATION_FLAG = 'userdraft/legacy_migrated_v1'
type LegacyKind = 'flow' | 'app' | 'raw_app'
const LEGACY_PREFIXES: ReadonlyArray<{ prefix: string; newKind: LegacyKind }> = [
// `rawapp` is listed before `app` even though our matcher uses exact /
// dash-separated comparison (so there's no ambiguity); it documents the
// intent that raw apps are a distinct kind, not a sub-case of apps.
{ prefix: 'rawapp', newKind: 'raw_app' },
{ prefix: 'flow', newKind: 'flow' },
{ prefix: 'app', newKind: 'app' }
]
/**
* A Windmill item path: `u/<owner>/<name…>` or `f/<folder>/<name…>`. The
* `<name…>` segment may itself contain slashes, so we don't constrain it
* past requiring at least one character. Used to reject incidentally-named
* localStorage keys (e.g. `app-recent` from a future feature, or a
* neighbouring app's data) before treating them as Windmill drafts.
*/
const LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/
function matchLegacyKey(
key: string
): { prefix: string; newKind: LegacyKind; path: string } | undefined {
for (const { prefix, newKind } of LEGACY_PREFIXES) {
if (key === prefix) return { prefix, newKind, path: '' }
if (key.startsWith(prefix + '-')) {
const path = key.slice(prefix.length + 1)
if (!LEGACY_PATH_SHAPE.test(path)) return undefined
return { prefix, newKind, path }
}
}
return undefined
}
function decodeLegacyState(raw: string): unknown {
try {
return JSON.parse(decodeURIComponent(atob(raw)))
} catch {
return undefined
}
}
/**
* Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are
* unusual enough that nothing else in the codebase has used them, but
* matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a
* Windmill draft (any base64-of-JSON could pass). Promoting a stray payload
* would silently surface as a phantom "Restored from local storage" toast
* on the next edit, so we reject anything that doesn't carry the fields the
* legacy writers actually produced.
*/
function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean {
if (decoded == null || typeof decoded !== 'object') return false
const obj = decoded as Record<string, unknown>
switch (kind) {
case 'flow':
// Legacy FlowBuilder wrote { flow, path, selectedId, draft_triggers, ... }.
return obj.flow != null && typeof obj.flow === 'object'
case 'app':
// Legacy AppEditor wrote `encodeState($appStore)`, i.e. the inner App
// value (see `frontend/src/lib/components/apps/types.ts`) — NOT the
// wrapping AppWithLastVersion. It carries `grid`, `fullscreen`,
// `theme`, `unusedInlineScripts`, `hiddenInlineScripts` among other
// fields — any one of those is a strong signal it's actually a
// Windmill app payload.
return (
'grid' in obj ||
'fullscreen' in obj ||
'theme' in obj ||
'unusedInlineScripts' in obj ||
'hiddenInlineScripts' in obj
)
case 'raw_app':
// Legacy RawAppEditor wrote { files, runnables, data }.
return 'files' in obj || 'runnables' in obj || 'data' in obj
}
}
function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown {
const obj = decoded as Record<string, unknown>
switch (kind) {
case 'flow':
// The legacy bundle wrapped the Flow alongside view-state fields
// (selectedId, draft_triggers, ...). The new entry stores only the
// Flow — the view-state lives elsewhere or is re-derived.
return obj.flow
case 'app':
// Legacy stored the App directly.
return obj
case 'raw_app':
// Legacy bundle missed the `summary` field that the new editor adds.
return {
files: obj.files ?? {},
runnables: obj.runnables ?? {},
data: obj.data ?? {},
summary: typeof obj.summary === 'string' ? obj.summary : ''
}
}
}
function newKey(workspace: string, kind: LegacyKind, path: string): string {
return `userdraft/w/${workspace}/${kind}/${path}`
}
function listLocalStorageKeys(): string[] {
const out: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k != null) out.push(k)
}
return out
}
/**
* Run the legacy new-format migration. Idempotent: returns immediately if a
* previous run completed (signalled by `MIGRATION_FLAG`).
*
* The migration is workspace-scoped because the legacy keys had no notion of
* workspace we treat the caller's current workspace as the owner of any
* surviving legacy entries.
*/
export function migrateLegacyUserDrafts(workspace: string): void {
if (typeof localStorage === 'undefined') return
if (!workspace) return
if (localStorage.getItem(MIGRATION_FLAG) !== null) return
try {
for (const key of listLocalStorageKeys()) {
const match = matchLegacyKey(key)
if (!match) continue
const raw = localStorage.getItem(key)
if (raw == null) continue
try {
const decoded = decodeLegacyState(raw)
if (!isPlausibleLegacyValue(match.newKind, decoded)) continue
const value = transformLegacyValue(match.newKind, decoded)
const target = newKey(workspace, match.newKind, match.path)
if (value !== undefined && localStorage.getItem(target) == null) {
// `lastWrittenAt` makes the migrated entry visible to
// `gcUserDrafts`. We stamp it as "now" so a freshly-migrated
// autosave gets the full retention window — sweeping it
// immediately on the first GC pass would lose work the
// legacy migration just rescued.
localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() }))
}
localStorage.removeItem(key)
} catch (e) {
console.error('UserDraft legacy migration: failed to migrate', key, e)
}
}
localStorage.setItem(MIGRATION_FLAG, new Date().toISOString())
} catch (e) {
console.error('UserDraft legacy migration: aborted', e)
}
}
/** Test-only: clear the sentinel so the migration can re-run. */
export function __resetUserDraftLegacyMigrationForTesting(): void {
try {
localStorage.removeItem(MIGRATION_FLAG)
} catch {
// ignore
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* "Restored from local storage" toast, shown when an editor reopens on a
* local autosave that differs from the backend. Owns only the wording and
* which reset actions are offered; the reset side-effects live at each call
* site (route-specific state).
*/
import { sendUserToast } from '$lib/toast'
export type RestoreFromLocalActions = {
/** Drop the local autosave, apply the backend DB draft. Offered when `hasBackendDraft`. */
onResetToSavedDraft?: () => void | Promise<void>
/** Drop the local autosave, load the deployed version. Offered when `hasDeployed`. */
onResetToDeployed?: () => void | Promise<void>
}
/** Show the toast with up to two reset actions, gated by what the backend has. */
export function notifyRestoredFromLocal(
hasBackendDraft: boolean,
hasDeployed: boolean,
{ onResetToSavedDraft, onResetToDeployed }: RestoreFromLocalActions
): void {
const actions: Array<{ label: string; callback: () => void | Promise<void> }> = []
if (hasBackendDraft && onResetToSavedDraft) {
actions.push({ label: 'Reset to saved draft', callback: onResetToSavedDraft })
}
if (hasDeployed && onResetToDeployed) {
actions.push({ label: 'Reset to deployed', callback: onResetToDeployed })
}
if (actions.length === 0) return
sendUserToast('Restored from local storage', false, actions)
}
@@ -58,6 +58,8 @@
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
import { gcUserDrafts } from '$lib/userDraft.svelte'
import { setContext, untrack } from 'svelte'
import { base } from '$app/paths'
import { Menubar } from '$lib/components/meltComponents'
@@ -364,8 +366,8 @@
async function loadCriticalAlertsMuted() {
let g_muted = true
const ws_muted =
(await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })).mute_critical_alerts ||
false
(await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! }))
.mute_critical_alerts || false
if ($superadmin) {
g_muted = (await SettingService.getGlobal({
@@ -418,6 +420,19 @@
$effect(() => {
$workspaceStore && untrack(() => onLoad())
})
$effect(() => {
if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!))
})
// Sweep UserDraft entries that haven't been touched in 30 days. Runs
// once on mount and on a 30-min timer so a single very long session
// also clears out stale autosaves over time. Live entries stamp
// `lastWrittenAt` on every persist, so the sweep only touches truly
// dormant records.
$effect(() => {
gcUserDrafts()
const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000)
return () => clearInterval(interval)
})
$effect(() => {
innerWidth && untrack(() => changeCollapsed())
})
@@ -4,10 +4,9 @@
import AppEditor from '$lib/components/apps/editor/AppEditor.svelte'
import { AppService, type Policy } from '$lib/gen'
import { page } from '$app/state'
import { decodeState } from '$lib/utils'
import { userStore, workspaceStore } from '$lib/stores'
import type { App } from '$lib/components/apps/types'
import { afterNavigate, replaceState } from '$app/navigation'
import { replaceState } from '$app/navigation'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { goto } from '$lib/navigation'
@@ -15,8 +14,19 @@
import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils'
import { emptyApp } from '$lib/components/apps/editor/appUtils'
import { tick } from 'svelte'
import { UserDraft } from '$lib/userDraft.svelte'
let nodraft = page.url.searchParams.get('nodraft')
// "+ App" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave and strip the flag from the URL
// synchronously so a reload doesn't wipe the freshly-started draft. A
// plain reload of /apps/add (no nodraft) instead restores the previous
// session via the child AppEditor's `UserDraft.use`.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('app', '')
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
let appEditor: AppEditor | undefined = $state(undefined)
const hubId = page.url.searchParams.get('hub')
const templatePath = page.url.searchParams.get('template')
@@ -27,8 +37,6 @@
$importStore = undefined
}
const appState = nodraft ? undefined : localStorage.getItem('app')
let summary = $state('')
let value: App = $state({
grid: [],
@@ -40,13 +48,6 @@
path: DEFAULT_THEME
}
})
afterNavigate(() => {
if (nodraft) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
}
})
let policy: Policy = $state({
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
@@ -59,6 +60,10 @@
async function loadApp() {
if (importRaw) {
// Import/template/hub loads are an explicit "start fresh from this
// content" — drop any previous empty-path autosave so it doesn't
// shadow the imported value on AppEditor mount.
UserDraft.remove('app', '')
sendUserToast('Loaded from YAML/JSON')
if ('value' in importRaw) {
summary = importRaw.summary
@@ -68,6 +73,7 @@
value = importRaw
}
} else if (templatePath) {
UserDraft.remove('app', '')
const template = await AppService.getAppByPath({
workspace: $workspaceStore!,
path: templatePath
@@ -76,6 +82,7 @@
sendUserToast('App loaded from template')
goto('?', { replaceState: true })
} else if (templateId) {
UserDraft.remove('app', '')
const template = await AppService.getAppByVersion({
workspace: $workspaceStore!,
id: parseInt(templateId)
@@ -84,6 +91,7 @@
sendUserToast('App loaded from template')
goto('?', { replaceState: true })
} else if (hubId) {
UserDraft.remove('app', '')
const hub = await AppService.getHubAppById({ id: Number(hubId) })
value = {
hiddenInlineScripts: [],
@@ -94,22 +102,6 @@
summary = hub.app.summary
sendUserToast('App loaded from Hub')
goto('?', { replaceState: true })
} else if (!templatePath && !hubId && appState) {
sendUserToast('App restored from browser stored autosave', false, [
{
label: 'Start from blank',
callback: () => {
value = {
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: [],
theme: undefined
}
}
}
])
value = decodeState(appState)
} else {
value = emptyApp()
}
@@ -121,7 +113,7 @@
await tick()
let attempts = 0
while (attempts < 20 && !document.querySelector('#app-editor-runnable-panel')) {
await new Promise(resolve => setTimeout(resolve, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
attempts++
}
appEditor?.triggerTutorial()
@@ -7,16 +7,19 @@
DraftService
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanValueProperties, decodeState, type Value } from '$lib/utils'
import { afterNavigate, replaceState } from '$app/navigation'
import { cleanValueProperties, orderedJsonStringify, type Value } from '$lib/utils'
import { replaceState } from '$app/navigation'
import { goto } from '$lib/navigation'
import { sendUserToast, type ToastAction } from '$lib/toast'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { App } from '$lib/components/apps/types'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
let app = $state(
undefined as (AppWithLastVersion & { draft_only?: boolean; value: any }) | undefined
@@ -33,19 +36,60 @@
}
| undefined = $state(undefined)
let redraw = $state(0)
let path = page.params.path ?? ''
let nodraft = page.url.searchParams.get('nodraft')
// Local-draft staleness modal: opened when the remote has moved on since
// the local autosave was written.
let staleModalOpen = $state(false)
let staleModalCause = $state<'draft' | 'version'>('version')
let pendingBaseline:
| { baseline: AppWithLastVersion & { draft_only?: boolean; value: any }; revs: UserDraftMeta }
| undefined = undefined
afterNavigate(() => {
if (nodraft) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
// 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
}
})
const initialState = nodraft ? undefined : localStorage.getItem(`app-${page.params.path}`)
let stateLoadedFromLocalStorage =
initialState != undefined ? decodeState(initialState) : undefined
// `discard` (not `remove`) so the entry's in-memory state.val is
// cleared synchronously. `redraw++` remounts AppEditor on the next
// microtask, but Svelte may mount the new instance before the old
// one's onDestroy releases its handle — the new instance would
// then re-acquire the SAME entry whose state.val still has the
// stale autosave, ignoring the just-emptied LS. Same reason every
// "reset" path below uses discard.
UserDraft.discard('app', path, undefined)
currentRevs = pendingBaseline.revs
app = pendingBaseline.baseline
pendingBaseline = undefined
staleModalOpen = false
redraw++
}
function onStaleKeepDraft(): void {
if (pendingBaseline) {
UserDraft.saveMeta('app', path, pendingBaseline.revs)
}
pendingBaseline = undefined
staleModalOpen = false
}
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before any descendant reads it. A plain reload
// (no nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('app', path)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
/** Increments per `loadApp` call. Stale loads (e.g. when picker
* navigation races a draft-discard reload) bail at the next checkpoint
@@ -80,101 +124,116 @@
custom_path: app_w_draft_.custom_path
}
if (stateLoadedFromLocalStorage) {
const reloadAction = async () => {
stateLoadedFromLocalStorage = undefined
await loadApp()
// Resolve the app value: backend draft > deployed, then overlay any
// local autosave from UserDraft if present.
const backendApp = app_w_draft.draft
? app_w_draft.summary !== undefined
? ({ ...app_w_draft, ...app_w_draft.draft } as AppWithLastVersion & {
draft_only?: boolean
value: any
})
: ({ ...app_w_draft, value: app_w_draft.draft } as AppWithLastVersion & {
draft_only?: boolean
value: any
})
: app_w_draft
const localDraftValue = UserDraft.get<App>('app', path)
const previousMeta = UserDraft.getMeta('app', path)
const newRevs: UserDraftMeta = {
remoteRev: app_w_draft.versions
? app_w_draft.versions[app_w_draft.versions.length - 1]
: undefined,
remoteDraftRev: app_w_draft.draft_created_at
}
currentRevs = newRevs
if (
localDraftValue != undefined &&
orderedJsonStringify(cleanValueProperties(localDraftValue)) !==
orderedJsonStringify(cleanValueProperties(backendApp.value))
) {
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
pendingBaseline = { baseline: backendApp, revs: newRevs }
staleModalCause = cause
staleModalOpen = true
} else {
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
// Legacy entry — backfill meta so the next load can detect staleness.
UserDraft.saveMeta('app', path, newRevs)
}
const appPath = backendApp.path
const hasBackendDraft = app_w_draft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.discard('app', path, undefined)
currentRevs = newRevs
app = backendApp
redraw++
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: appPath
})
}
UserDraft.discard('app', path, undefined)
goto(`/apps/edit/${appPath}`)
await loadApp()
redraw++
}
})
}
app = { ...backendApp, value: localDraftValue }
} else {
// Local is missing or matches backend — wipe any stale entry so it
// doesn't haunt the next session and use the backend value.
if (localDraftValue != undefined) UserDraft.remove('app', path)
app = backendApp
}
if (app_w_draft.draft && !app_w_draft.draft_only && localDraftValue == undefined) {
const reloadAction = () => {
app = app_w_draft
redraw++
}
const actions: ToastAction[] = []
if (stateLoadedFromLocalStorage) {
actions.push({
label: 'Discard browser autosave and reload',
callback: reloadAction
})
const draftOrDeployed = cleanValueProperties(savedApp?.draft || savedApp)
const urlScript = {
...draftOrDeployed,
value: stateLoadedFromLocalStorage
}
actions.push({
const deployed = cleanValueProperties(app_w_draft as Value)
const draft = cleanValueProperties(app ?? {})
sendUserToast('app loaded from latest saved draft', false, [
{
label: 'Reset to deployed',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: draftOrDeployed,
current: urlScript,
title: `${savedApp?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: { text: 'Discard autosave', onClick: reloadAction }
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
})
}
sendUserToast('App restored from browser storage', false, actions)
app_w_draft.value = stateLoadedFromLocalStorage
app = app_w_draft
} else if (app_w_draft.draft) {
if (app_w_draft.summary !== undefined) {
// backward compatibility for old drafts missing metadata
app = {
...app_w_draft,
...app_w_draft.draft
}
} else {
app = {
...app_w_draft,
value: app_w_draft.draft as any
}
}
if (!app_w_draft.draft_only) {
const reloadAction = () => {
stateLoadedFromLocalStorage = undefined
app = app_w_draft
redraw++
}
const deployed = cleanValueProperties(app_w_draft as Value)
const draft = cleanValueProperties(app ?? {})
sendUserToast('app loaded from latest saved draft', false, [
{
label: 'Discard draft and load from latest deployed version',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
app = app_w_draft
])
}
}
$effect(() => {
// Re-run on workspace OR path change so navigating from one app editor
// to another (e.g. via the workspace picker) reloads the new app.
const newPath = page.params.path
if ($workspaceStore) {
const currentPath = page.params.path
if ($workspaceStore && currentPath !== undefined) {
untrack(() => {
// Clear the app so AppEditor unmounts; it will remount once loadApp
// completes with fresh data, re-initializing its internal stores.
app = undefined
const s = nodraft ? undefined : localStorage.getItem(`app-${newPath}`)
stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined
path = currentPath
loadApp()
})
}
@@ -186,6 +245,7 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.discard('app', path, undefined)
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
@@ -204,6 +264,7 @@
path: savedApp.path
})
}
UserDraft.discard('app', path, undefined)
goto(`/apps/edit/${savedApp.path}`)
await loadApp()
redraw++
@@ -227,6 +288,12 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#key redraw}
{#if app}
@@ -248,6 +315,7 @@
{diffDrawer}
version={app.versions ? app.versions[app.versions.length - 1] : undefined}
newApp={false}
initialRevs={currentRevs}
replaceStateFn={(path) => replaceState(path, page.state)}
gotoFn={(path, opt) => goto(path, opt)}
>
@@ -3,15 +3,16 @@
import { AppService, type Policy } from '$lib/gen'
import { page } from '$app/state'
import { decodeState } from '$lib/utils'
import { userStore, workspaceStore } from '$lib/stores'
import { afterNavigate, replaceState } from '$app/navigation'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import FileEditorIcon from '$lib/components/raw_apps/FileEditorIcon.svelte'
import { UserDraft, localDraftDiffers } from '$lib/userDraft.svelte'
import { readFieldsRecursively } from '$lib/utils'
import { untrack } from 'svelte'
import {
react18Template,
react19Template,
@@ -40,11 +41,26 @@
import { Alert } from '$lib/components/common'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
let nodraft = page.url.searchParams.get('nodraft')
// `nodraft` is captured into a local because we strip it from the URL
// below — downstream readers like `templatePicker` must see the original
// signal.
const nodraft = page.url.searchParams.get('nodraft')
const templatePath = page.url.searchParams.get('template')
const templateId = page.url.searchParams.get('template_id')
const hubId = page.url.searchParams.get('hub')
// "+ Raw App" / "+ App > Full code" buttons navigate with ?nodraft=true to
// signal "start fresh". Wipe the persisted empty-path autosave and strip
// the flag from the URL synchronously so a reload doesn't wipe the
// freshly-started draft. A plain reload of /apps_raw/add (no nodraft)
// instead restores the previous session.
if (nodraft && typeof window !== 'undefined') {
UserDraft.discard('raw_app', '', undefined)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
// Check in-memory store first, then sessionStorage (used when full page reload occurs)
let importRaw = $importStore
if ($importStore) {
@@ -58,26 +74,19 @@
}
}
const appState = nodraft || hubId ? undefined : localStorage.getItem('rawapp')
const draftHandle = UserDraft.use<{
files: Record<string, string>
runnables: Record<string, Runnable>
data: RawAppData
summary: string
}>('raw_app', '')
// Restore the persisted autosave so a plain reload of /apps_raw/add
// resumes the last session. Captured once; the $effect below mirrors
// later edits back. Import/template/hub flows in loadApp() wipe the
// entry first (`UserDraft.remove`) for "start fresh" semantics.
const restoredDraft = untrack(() => draftHandle.draft)
let summary = $state('')
let files: Record<string, string> = $state(react19Template)
afterNavigate(() => {
if (nodraft) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
}
})
let policy: Policy = $state({
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
})
let runnables: Record<string, Runnable> = $state({
const defaultRunnables: Record<string, Runnable> = {
a: {
name: 'a',
fields: {},
@@ -101,9 +110,55 @@
}
}
}
}
let summary = $state(restoredDraft?.summary ?? '')
let files: Record<string, string> = $state(restoredDraft?.files ?? react19Template)
let policy: Policy = $state({
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
})
let runnables: Record<string, Runnable> = $state(restoredDraft?.runnables ?? defaultRunnables)
/** Data configuration including tables and creation policy */
let data: RawAppData = $state({ ...DEFAULT_DATA })
let data: RawAppData = $state(restoredDraft?.data ?? { ...DEFAULT_DATA })
// First mirror consumes the handle's first-write skip up-front (wipe
// then restore) so the user's first real edit isn't the one dropped.
let firstMirror = true
$effect(() => {
readFieldsRecursively(files)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
void summary
untrack(() => {
if (firstMirror) {
firstMirror = false
draftHandle.setDraftAndMeta(undefined, {})
}
draftHandle.draft = { files, runnables, data, summary }
})
})
// Reflect an external UserDraft.saveExternal into the form. Idempotent + the
// d == null guard keeps it from looping with the mirror above or
// clobbering "start fresh" loads (which discard the in-memory draft).
$effect(() => {
const d = draftHandle.draft
if (d == null) return
untrack(() => {
if (localDraftDiffers(d, { files, runnables, data, summary })) {
files = d.files
runnables = d.runnables
data = d.data
summary = d.summary
}
})
})
loadApp()
function extractValue(value: any) {
@@ -128,6 +183,10 @@
}
async function loadApp() {
if (importRaw) {
// Import/template/hub loads are an explicit "start fresh from this
// content" — drop the restored empty-path autosave so it doesn't
// linger as the next plain reload's baseline.
UserDraft.discard('raw_app', '', undefined)
sendUserToast('Loaded from YAML/JSON')
if ('value' in importRaw) {
summary = importRaw.summary
@@ -139,6 +198,7 @@
}
console.log('importRaw', importRaw)
} else if (templatePath) {
UserDraft.discard('raw_app', '', undefined)
const template = await AppService.getAppByPath({
workspace: $workspaceStore!,
path: templatePath
@@ -148,6 +208,7 @@
sendUserToast('App loaded from template path')
goto('?', { replaceState: true })
} else if (templateId) {
UserDraft.discard('raw_app', '', undefined)
const template = await AppService.getAppByVersion({
workspace: $workspaceStore!,
id: parseInt(templateId)
@@ -157,6 +218,7 @@
sendUserToast('App loaded from template')
goto('?', { replaceState: true })
} else if (hubId) {
UserDraft.discard('raw_app', '', undefined)
const hub = await AppService.getHubRawAppById({ id: Number(hubId) })
if (hub.app?.value) {
extractValue(hub.app.value)
@@ -167,19 +229,6 @@
console.log('App loaded from Hub')
sendUserToast('App loaded from Hub')
goto('?', { replaceState: true })
} else if (!templatePath && !hubId && appState) {
console.log('App loaded from browser stored autosave')
sendUserToast('App restored from browser stored autosave', false, [
{
label: 'Start from blank',
callback: () => {
files = {}
runnables = {}
}
}
])
let decoded = decodeState(appState)
extractValue(decoded)
}
}
@@ -1,18 +1,39 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { untrack } from 'svelte'
import { AppService, DraftService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanValueProperties, decodeState, type Value } from '$lib/utils'
import { afterNavigate, replaceState } from '$app/navigation'
import {
cleanValueProperties,
orderedJsonStringify,
readFieldsRecursively,
type Value
} from '$lib/utils'
import { goto } from '$lib/navigation'
import { sendUserToast, type ToastAction } from '$lib/toast'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { HiddenRunnable } from '$lib/components/apps/types'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { page } from '$app/state'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import {
UserDraft,
checkStaleness,
localDraftDiffers,
type UserDraftMeta
} from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
type RawAppDraft = {
files: Record<string, string>
runnables: Record<string, any>
data: RawAppData
summary: string
}
let files: Record<string, string> | undefined = $state(undefined)
let runnables = $state({})
/** Data configuration including tables and creation policy */
@@ -37,15 +58,74 @@
}
| undefined = $state(undefined)
let redraw = $state(0)
let path = page.params.path ?? ''
let nodraft = page.url.searchParams.get('nodraft')
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before the handle is created. A plain reload (no
// nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('raw_app', path)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
afterNavigate(() => {
if (nodraft) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
const draftHandle = UserDraft.use<RawAppDraft>('raw_app', path)
// Local-draft staleness modal: opened when the remote has moved on since
// the local autosave was written.
let staleModalOpen = $state(false)
let staleModalCause = $state<'draft' | 'version'>('version')
let pendingBaseline:
| { baseline: RawAppDraft; backendSource: any; revs: UserDraftMeta }
| undefined = undefined
function onStaleLoadLatest(): void {
if (!pendingBaseline) {
staleModalOpen = false
return
}
const { baseline, backendSource, revs } = pendingBaseline
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(baseline, revs)
extractRawApp(backendSource)
pendingBaseline = undefined
staleModalOpen = false
redraw++
}
function onStaleKeepDraft(): void {
if (pendingBaseline) {
draftHandle.setMeta(pendingBaseline.revs, { force: true })
}
pendingBaseline = undefined
staleModalOpen = false
}
// Persist the bundle whenever any of the four pieces of state changes.
$effect(() => {
if (!files) return
readFieldsRecursively(files)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
void summary
draftHandle.draft = { files, runnables, data, summary }
})
// Reflect an external UserDraft.saveExternal into the form. Idempotent; the
// `!files` guard skips the reload window so it doesn't fight loadApp.
$effect(() => {
const d = draftHandle.draft
if (d == null || !files) return
untrack(() => {
if (localDraftDiffers(d, { files, runnables, data, summary })) {
files = d.files
runnables = d.runnables
data = d.data
summary = d.summary
}
})
})
function extractRawApp(app: any) {
@@ -73,10 +153,6 @@
newPath = app.path
}
const initialState = nodraft ? undefined : localStorage.getItem(`rawapp-${page.params.path}`)
let stateLoadedFromLocalStorage =
initialState != undefined ? decodeState(initialState) : undefined
/** 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. */
@@ -99,48 +175,81 @@
custom_path: app_w_draft_.custom_path
}
if (stateLoadedFromLocalStorage) {
const reloadAction = async () => {
stateLoadedFromLocalStorage = undefined
await loadApp()
redraw++
}
const actions: ToastAction[] = []
if (stateLoadedFromLocalStorage) {
actions.push({
label: 'Discard browser autosave and reload',
callback: reloadAction
})
const backendSource: any = app_w_draft.draft ? app_w_draft.draft : app_w_draft
const localDraft = draftHandle.draft
const previousMeta = draftHandle.meta
const newRevs: UserDraftMeta = {
remoteRev: app_w_draft.versions
? app_w_draft.versions[app_w_draft.versions.length - 1]
: undefined,
remoteDraftRev: app_w_draft.draft_created_at
}
const backendBundle: RawAppDraft = {
files: backendSource.value?.files ?? {},
runnables: backendSource.value?.runnables ?? {},
data:
backendSource.value?.data ??
(backendSource.value?.datatables
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
: { ...DEFAULT_DATA }),
summary: backendSource.summary ?? ''
}
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
const urlScript = {
...draftOrDeployed,
value: stateLoadedFromLocalStorage
if (
localDraft != undefined &&
orderedJsonStringify(cleanValueProperties(localDraft)) !==
orderedJsonStringify(cleanValueProperties(backendBundle))
) {
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
pendingBaseline = { baseline: backendBundle, backendSource, revs: newRevs }
staleModalCause = cause
staleModalOpen = true
} else {
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
// Legacy entry — backfill meta so the next load can detect staleness.
draftHandle.setMeta(newRevs, { force: true })
}
actions.push({
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: draftOrDeployed,
current: urlScript,
title: `${savedApp?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: { text: 'Discard autosave', onClick: reloadAction }
})
const appPath = app_w_draft.path
const hasBackendDraft = app_w_draft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !app_w_draft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(backendBundle, newRevs)
extractRawApp(backendSource)
redraw++
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'app',
path: appPath
})
}
UserDraft.remove('raw_app', path)
// UserDraft.remove only clears localStorage. Drop the
// entry's in-memory state too so loadApp doesn't re-read
// the stale autosave and re-fire the same toast.
draftHandle.setDraftAndMeta(undefined, {})
await loadApp()
redraw++
}
})
}
sendUserToast('App restored from browser storage', false, actions)
app_w_draft.value = stateLoadedFromLocalStorage
extractRawApp(app_w_draft)
redraw += 1
} else if (app_w_draft.draft) {
extractRawApp(app_w_draft.draft)
runnables = localDraft.runnables
data = localDraft.data
summary = localDraft.summary
policy = app_w_draft.policy
newPath = app_w_draft.path
files = localDraft.files
} else {
if (localDraft != undefined) UserDraft.remove('raw_app', path)
extractRawApp(backendSource)
draftHandle.setDraftAndMeta(backendBundle, newRevs)
if (!app_w_draft.draft_only) {
if (app_w_draft.draft && !app_w_draft.draft_only) {
const reloadAction = () => {
stateLoadedFromLocalStorage = undefined
extractRawApp(app_w_draft)
redraw++
}
@@ -149,7 +258,7 @@
const draft = cleanValueProperties({ files, runnables })
sendUserToast('app loaded from latest saved draft', false, [
{
label: 'Discard draft and load from latest deployed version',
label: 'Reset to deployed',
callback: reloadAction
},
{
@@ -167,21 +276,18 @@
}
])
}
} else {
extractRawApp(app_w_draft)
}
}
run(() => {
// Re-run on workspace OR path change so navigating from one raw app editor
// to another (e.g. via the workspace picker) reloads the new app.
const newPath = page.params.path
if ($workspaceStore) {
const currentPath = page.params.path
if ($workspaceStore && currentPath !== undefined) {
// Clear files so RawAppEditor unmounts; it will remount when loadApp
// completes with fresh data, re-initializing its internal stores.
files = undefined
const s = nodraft ? undefined : localStorage.getItem(`rawapp-${newPath}`)
stateLoadedFromLocalStorage = s != undefined ? decodeState(s) : undefined
path = currentPath
loadApp()
}
})
@@ -192,6 +298,12 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('raw_app', path)
// Drop the in-memory handle state so loadApp sees no local draft
// on the next pass — otherwise the staleness check would compare
// the stale in-memory meta against the freshly fetched backend and
// fire a spurious modal.
draftHandle.setDraftAndMeta(undefined, {})
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
@@ -210,6 +322,8 @@
path: savedApp.path
})
}
UserDraft.remove('raw_app', path)
draftHandle.setDraftAndMeta(undefined, {})
goto(`/apps/edit/${savedApp.path}`)
await loadApp()
redraw++
@@ -233,12 +347,19 @@
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if files}
{#key redraw}
<div class="h-screen">
<RawAppEditor
on:savedNewAppPath={(event) => {
UserDraft.remove('raw_app', path)
goto(`/apps_raw/edit/${event.detail}`)
newPath = event.detail
}}
@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import { afterNavigate, replaceState } from '$app/navigation'
import { page } from '$app/state'
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
@@ -14,16 +13,19 @@
import { tick } from 'svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
import { UserDraft } from '$lib/userDraft.svelte'
let nodraft = page.url.searchParams.get('nodraft')
afterNavigate(() => {
if (nodraft) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
}
})
// "+ Flow" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave and strip the flag from the URL
// synchronously so a reload doesn't wipe the freshly-started draft. A
// plain reload of /flows/add (no nodraft) instead restores whatever the
// user was last working on.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('flow', '')
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
const hubId = page.url.searchParams.get('hub')
const templatePath = page.url.searchParams.get('template')
@@ -44,9 +46,6 @@
}
}
const initialState =
hubId || templatePath || nodraft || isFork ? undefined : localStorage.getItem('flow')
let selectedId: string = $state('settings-metadata')
let loading = $state(false)
@@ -61,8 +60,8 @@
// initialArgs may also be set from decoded state below (e.g. fork preview)
let flowBuilder: FlowBuilder | undefined = $state(undefined)
const flowStore: StateStore<Flow> = $state({
val: {
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
@@ -72,25 +71,32 @@
extra_perms: {},
schema: emptySchema()
}
})
}
const flowHandle = UserDraft.use<Flow>('flow', '', { defaultValue: emptyFlow() })
const flowStore: StateStore<Flow> = {
get val() {
return flowHandle.draft ?? emptyFlow()
},
set val(v: Flow) {
flowHandle.draft = v
}
}
const flowStateStore = $state({ val: {} })
let draftTriggersFromUrl: Trigger[] | undefined = $state(undefined)
let selectedTriggerIndexFromUrl: number | undefined = $state(undefined)
async function loadFlow() {
loading = true
let flow: Flow = {
path: '',
summary: '',
value: { modules: [] },
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
// Start from the persisted autosave, not a fresh `emptyFlow()`. The
// branches below override `flow` when the user explicitly asked for a
// different starting point (import/fork/URL state/template/hub); a
// plain reload of /flows/add (no query params) falls through with
// the LS value intact so the user's last session is restored.
let flow: Flow = flowHandle.draft ?? emptyFlow()
let state = forkState ?? (initialState ? decodeState(initialState) : undefined)
let state = forkState
const initialStateQuery = page.url.hash != '' ? page.url.hash.slice(1) : undefined
if (initialStateQuery) {
@@ -101,24 +107,6 @@
$importFlowStore = undefined
sendUserToast('Flow loaded from YAML/JSON')
} else if (!templatePath && !hubId && state) {
sendUserToast('Flow restored from draft', false, [
{
label: 'Start from blank instead',
callback: () => {
flowStore.val = {
summary: '',
value: { modules: [] },
path: '',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {},
schema: emptySchema()
}
}
}
])
flow = state.flow
pathStoreInit = state.path
if (state.initialArgs) {
@@ -143,12 +131,15 @@
path: templatePath
})
}
// Template/hub flows are an explicit "start fresh from this
// content" — drop any previous empty-path autosave and use
// the freshly built flow as the baseline.
flow = emptyFlow()
Object.assign(flow, template)
const oldPath = templatePath.split('/')
initialPath = `u/${$userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')}/${
oldPath[oldPath.length - 1]
}_fork`
flow = flow
goto('?', { replaceState: true })
selectedId = 'settings-metadata'
} else if (hubId) {
@@ -157,6 +148,7 @@
initialPath = `u/${$userStore?.username
.split('@')[0]
.replace(/[^a-zA-Z0-9_]/g, '')}/flow_${hubId}`
flow = emptyFlow()
Object.assign(flow, hub.flow)
if (flow.value.preprocessor_module?.value.type === 'rawscript') {
flow.value.preprocessor_module.value.content = replaceScriptPlaceholderWithItsValues(
@@ -164,7 +156,6 @@
flow.value.preprocessor_module.value.content
)
}
flow = flow
goto('?', { replaceState: true })
selectedId = 'constants'
}
@@ -194,10 +185,12 @@
<FlowBuilder
onSaveInitial={(e) => {
UserDraft.remove('flow', '')
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/edit/${e.path}?selected=${e.id}`)
}}
onDeploy={(e) => {
UserDraft.remove('flow', '')
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
@@ -13,23 +13,25 @@
} from '$lib/utils'
import { initFlow } from '$lib/components/flows/flowStore.svelte'
import { goto } from '$lib/navigation'
import { afterNavigate, replaceState } from '$app/navigation'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { untrack } from 'svelte'
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
let version: undefined | number = $state(undefined)
// `initialArgs` is captured once at mount — it's the session's initial
// argument set. Per-flow autosave (`stateLoadedFromUrl`) and the
// `nodraft` flag are re-read inside `loadFlow` / the navigation hook so
// picker navigation doesn't reuse the original path's state.
// argument set. The flow draft itself lives in UserDraft and is re-read
// per loadFlow() so picker navigation doesn't reuse the original path's
// state.
const urlArgs = page.url.searchParams.get('initial_args')
let initialArgs = $state({})
@@ -46,16 +48,23 @@
})
| undefined = $state(undefined)
afterNavigate(() => {
if (page.url.searchParams.get('nodraft')) {
let url = new URL(page.url.href)
url.search = ''
replaceState(url.toString(), page.state)
}
})
const flowDraftPath = page.params.path ?? ''
export const flowStore: StateStore<Flow> = $state({
val: {
// `?nodraft=true` is the callers' way of saying "skip the local autosave
// on this load." Wipe the UserDraft entry and strip the flag from the
// URL synchronously, before the handle is created — same pattern as
// /flows/add. A plain reload (no nodraft) restores normally.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('flow', flowDraftPath)
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
const flowHandle = UserDraft.use<Flow>('flow', flowDraftPath)
function emptyFlow(): Flow {
return {
summary: '',
value: { modules: [] },
path: '',
@@ -65,7 +74,16 @@
extra_perms: {},
schema: emptySchema()
}
})
}
export const flowStore: StateStore<Flow> = {
get val() {
return flowHandle.draft ?? emptyFlow()
},
set val(v: Flow) {
flowHandle.draft = v
}
}
const flowStateStore = $state({ val: {} })
let loading = $state(false)
@@ -74,15 +92,34 @@
let nobackenddraft = false
// One-shot read of mount-time autosave, used only to seed the initial
// `savedPrimarySchedule` before `loadFlow` runs. `loadFlow` itself
// re-reads localStorage on every invocation (see comment there).
const initialAutosave = (() => {
if (page.url.searchParams.get('nodraft')) return undefined
const raw = localStorage.getItem(`flow-${page.params.path}`)
return raw != undefined ? decodeState(raw) : undefined
})()
let savedPrimarySchedule: ScheduleTrigger | undefined = $state(initialAutosave?.primarySchedule)
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)
@@ -100,77 +137,127 @@
async function loadFlow(): Promise<void> {
const tok = ++loadFlowToken
loading = true
// Re-read autosave per load. The component doesn't remount when the
// picker navigates between flows, so capturing at module init would
// keep reusing the original flow's state.
const stored = page.url.searchParams.get('nodraft')
? undefined
: localStorage.getItem(`flow-${page.params.path}`)
const stateLoadedFromUrl = stored != undefined ? decodeState(stored) : undefined
let flow: Flow
let statePath = stateLoadedFromUrl?.path
if (stateLoadedFromUrl != undefined && statePath == page.params.path) {
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: statePath
})
)?.id
if (tok !== loadFlowToken) return
version = v
if (version == undefined) {
notFound = true
sendUserToast(`Flow not found at path ${statePath}`, true)
return
}
const sf = await FlowService.getFlowByPathWithDraft({
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: statePath
path: page.params.path ?? ''
})
if (tok !== loadFlowToken) return
savedFlow = sf
).id
if (tok !== loadFlowToken) return
version = v
const draftOrDeployed = cleanValueProperties(savedFlow?.draft || savedFlow)
const urlScript = cleanValueProperties(
$state.snapshot({
...stateLoadedFromUrl.flow,
draft_triggers: stateLoadedFromUrl.draft_triggers
})
)
flow = stateLoadedFromUrl.flow
draftTriggersFromUrl = stateLoadedFromUrl.draft_triggers
selectedTriggerIndexFromUrl = stateLoadedFromUrl.selected_trigger
loadedFromHistoryFromUrl = stateLoadedFromUrl.loadedFromHistory
flowBuilder?.setDraftTriggers(draftTriggersFromUrl)
flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl)
flowBuilder?.setLoadedFromHistory(loadedFromHistoryFromUrl)
const selectedId = stateLoadedFromUrl?.selectedId ?? 'settings-metadata'
const reloadAction = () => {
// Discard the localStorage autosave so the next `loadFlow`
// (re-)read sees an empty slot and falls through to the
// fetch branch — otherwise we'd re-enter this branch and
// loop. Scripts dodge this because their state lives in
// the URL fragment, which `goto` clears for us.
try {
localStorage.removeItem(`flow-${statePath}`)
} catch (e) {
console.error('error interacting with local storage', e)
}
goto(`/flows/edit/${statePath}?selected=${selectedId}`)
loadFlow()
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadFlowToken) return
savedFlow = {
...structuredClone($state.snapshot(flowWithDraft)),
draft: flowWithDraft.draft
? {
...structuredClone($state.snapshot(flowWithDraft.draft)),
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
}
: undefined
} as Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) {
reloadAction()
}
const backendFlow =
flowWithDraft.draft != undefined && !nobackenddraft ? flowWithDraft.draft : flowWithDraft
const localDraft = flowHandle.draft
const previousMeta = flowHandle.meta
const newRevs: UserDraftMeta = {
remoteRev: v,
remoteDraftRev: flowWithDraft.draft_created_at
}
if (localDraft != undefined) {
const localClean = cleanValueProperties(localDraft)
const backendClean = cleanValueProperties(backendFlow)
if (orderedJsonStringify(localClean) === orderedJsonStringify(backendClean)) {
// Local matches backend exactly — silently drop the autosave.
flow = backendFlow
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(backendFlow, newRevs)
} else {
sendUserToast('Flow loaded from browser storage', false, [
flow = localDraft
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
pendingBaseline = { baseline: backendFlow, revs: newRevs }
staleModalCause = cause
staleModalOpen = true
} else {
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
// Legacy entry — backfill meta so the next load can detect staleness.
flowHandle.setMeta(newRevs, { force: true })
}
const flowPath = backendFlow.path
const hasBackendDraft = flowWithDraft.draft != undefined
notifyRestoredFromLocal(hasBackendDraft, !flowWithDraft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(backendFlow, newRevs)
loadFlow()
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flowPath
})
}
UserDraft.remove('flow', flowDraftPath)
// UserDraft.remove only clears localStorage. Drop the
// entry's in-memory state too so loadFlow doesn't re-read
// the stale autosave and re-fire the same toast.
flowHandle.setDraftAndMeta(undefined, {})
nobackenddraft = true
loadFlow()
}
})
}
}
} else {
flow = backendFlow
flowHandle.setDraftAndMeta(backendFlow, newRevs)
}
if (flowWithDraft.draft != undefined && !nobackenddraft) {
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
if (!flowWithDraft.draft_only && localDraft == undefined) {
const deployed = cleanValueProperties(flowWithDraft)
const draft = cleanValueProperties(flow)
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flow.path
})
UserDraft.remove('flow', flowDraftPath)
// UserDraft.remove only clears localStorage. The
// flowHandle's in-memory state still holds the now-
// deleted DB draft + its meta — loadFlow would treat it
// as a local autosave and the staleness check would fire
// a spurious "newer version was deployed" modal because
// remoteDraftRev moved from "defined" to "undefined".
// Drop the in-memory state first.
flowHandle.setDraftAndMeta(undefined, {})
nobackenddraft = true
loadFlow()
}
sendUserToast('flow loaded from latest saved draft', false, [
{
label: 'Discard browser autosave and reload',
label: 'Reset to deployed',
callback: reloadAction
},
{
@@ -179,93 +266,23 @@
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: draftOrDeployed,
current: urlScript,
title: `${savedFlow?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: { text: 'Discard autosave', onClick: reloadAction }
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
// Currently there is no way to get version of flow with flow.
// So we have to request it here
const v = (
await FlowService.getFlowLatestVersion({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
).id
if (tok !== loadFlowToken) return
version = v
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadFlowToken) return
savedFlow = {
...structuredClone($state.snapshot(flowWithDraft)),
draft: flowWithDraft.draft
? {
...structuredClone($state.snapshot(flowWithDraft.draft)),
path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path
}
: undefined
} as Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
}
if (flowWithDraft.draft != undefined && !nobackenddraft) {
flow = flowWithDraft.draft
savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule']
flowBuilder?.setPrimarySchedule(savedPrimarySchedule)
flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers'])
if (!flowWithDraft.draft_only) {
const deployed = cleanValueProperties(flowWithDraft)
const draft = cleanValueProperties(flow)
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'flow',
path: flow.path
})
nobackenddraft = true
loadFlow()
}
sendUserToast('flow loaded from latest saved draft', false, [
{
label: 'Discard draft and load from latest deployed version',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
flow = flowWithDraft
flowBuilder?.setDraftTriggers(undefined)
}
flowBuilder?.setDraftTriggers(undefined)
}
await initFlow(flow, flowStore, flowStateStore)
if (tok !== loadFlowToken) return
loading = false
selectedId = stateLoadedFromUrl?.selectedId ?? page.url.searchParams.get('selected')
selectedId = page.url.searchParams.get('selected') ?? 'settings-metadata'
flowBuilder?.loadFlowState()
}
@@ -286,6 +303,12 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('flow', flowDraftPath)
// Drop the in-memory handle state so loadFlow sees no local draft
// on the next pass — otherwise the staleness check would compare
// the stale in-memory meta against the freshly fetched backend and
// fire a spurious modal.
flowHandle.setDraftAndMeta(undefined, {})
goto(`/flows/edit/${savedFlow.draft.path}`)
loadFlow()
}
@@ -303,6 +326,8 @@
path: savedFlow.path
})
}
UserDraft.remove('flow', flowDraftPath)
flowHandle.setDraftAndMeta(undefined, {})
goto(`/flows/edit/${savedFlow.path}`)
loadFlow()
}
@@ -311,6 +336,12 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if notFound}
<div class="flex flex-col items-center justify-center h-full">
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>
@@ -319,6 +350,7 @@
{:else}
<FlowBuilder
onDeploy={(e) => {
UserDraft.remove('flow', flowDraftPath)
if ($workspaceStore) invalidate($workspaceStore, 'flow')
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
@@ -1,9 +1,11 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import {
globalDraftStore,
type WorkspaceItem
} from '$lib/components/copilot/chat/global/draftStore.svelte'
clearGlobalDrafts,
deleteGlobalDraft,
listGlobalDrafts
} from '$lib/components/copilot/chat/global/userDraftAdapter'
import { type WorkspaceItem } from '$lib/components/copilot/chat/global/workspaceItems'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { goto } from '$lib/navigation'
import { workspaceStore } from '$lib/stores'
@@ -20,7 +22,11 @@
}
})
let drafts = $derived($workspaceStore ? globalDraftStore.listDrafts($workspaceStore) : [])
let refreshRevision = $state(0)
let drafts = $derived.by(() => {
refreshRevision
return $workspaceStore ? listGlobalDrafts($workspaceStore) : []
})
function draftKey(item: WorkspaceItem): string {
return `${item.type}:${item.triggerKind ?? '-'}:${item.path}`
@@ -28,12 +34,14 @@
function deleteDraft(item: WorkspaceItem) {
if (!$workspaceStore) return
globalDraftStore.deleteDraft($workspaceStore, item.type, item.path, item.triggerKind)
deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
refreshRevision += 1
}
function clearAll() {
if (!$workspaceStore) return
globalDraftStore.clearDrafts($workspaceStore)
clearGlobalDrafts($workspaceStore)
refreshRevision += 1
}
</script>
@@ -43,7 +51,7 @@
<div>
<h1 class="text-2xl font-semibold">Global AI drafts</h1>
<p class="text-sm text-tertiary">
Dev-only inspector for the in-memory global draft store.
Dev-only inspector for drafts written by global AI mode.
</p>
</div>
<Button
@@ -83,6 +91,8 @@
variant="default"
startIcon={{ icon: Trash2 }}
iconOnly
aria-label={`Delete draft ${draft.path}`}
title={`Delete draft ${draft.path}`}
onclick={() => deleteDraft(draft)}
/>
</div>
@@ -8,7 +8,6 @@
import type { Schema } from '$lib/common'
import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils'
import { goto } from '$lib/navigation'
import { replaceState } from '$app/navigation'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
@@ -17,6 +16,7 @@
import ScriptEditorSkeleton from '$lib/components/ScriptEditorSkeleton.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
import { UserDraft } from '$lib/userDraft.svelte'
type Script = NewScript & {
draft_triggers?: Trigger[]
@@ -35,25 +35,46 @@
const wacParam = page.url.searchParams.get('wac')
const importParam = page.url.searchParams.get('import')
/** Some pages (run/[...run]'s "Fork" action, workspace_settings'
* error/success-handler template buttons) base64-JSON-encode a NewScript
* payload into the URL hash. That value is an explicit "open this script"
* intent and wins over local autosave, templates, hubs, and YAML imports.
*
* We can't use `decodeState` from utils.ts directly — it fires its own
* "Impossible to parse state" toast on failure, which would noise up the
* UI when the hash isn't a script payload at all (e.g. a route anchor).
*/
function decodeUrlScript(): Partial<Script> | undefined {
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
if (!fragment) return undefined
try {
const decoded = JSON.parse(decodeURIComponent(atob(fragment)))
if (decoded && typeof decoded === 'object') return decoded as Partial<Script>
} catch {
// Hash isn't a valid encoded script — ignore.
}
return undefined
}
const urlScript = decodeUrlScript()
// "+ Script" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave and strip the flag from the URL
// synchronously so a reload doesn't wipe the freshly-started draft. A
// plain reload of /scripts/add (no nodraft) instead restores the
// previous session.
if (page.url.searchParams.get('nodraft') && typeof window !== 'undefined') {
UserDraft.remove('script', '')
const url = new URL(window.location.href)
url.searchParams.delete('nodraft')
window.history.replaceState(window.history.state, '', url.toString())
}
let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {})
if (get(initialArgsStore)) $initialArgsStore = undefined
const path = page.url.searchParams.get('path')
const initialState = page.url.hash != '' ? page.url.hash.slice(1) : undefined
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
function decodeStateAndHandleError(state) {
try {
const decoded = decodeState(state)
return decoded
} catch (e) {
console.error('Error decoding state', e)
return defaultScript()
}
}
function defaultScript(): Script {
return {
hash: '',
@@ -74,22 +95,35 @@
}
}
let script: Script | undefined = $state(
templatePath || hubPath
? undefined
: !path && initialState != undefined
? decodeStateAndHandleError(initialState)
: defaultScript()
)
// templatePath/hubPath/import/url-hash flows replace the value before
// render, so defaultValue is left undefined for those to avoid flashing a
// blank editor.
const scriptHandle = UserDraft.use<Script>('script', '', {
defaultValue: templatePath || hubPath || urlScript ? undefined : defaultScript()
})
if (urlScript) {
scriptHandle.draft = { ...defaultScript(), ...urlScript }
sendUserToast('Loaded from URL')
// Consume-once: strip the hash so a reload restores the user's
// autosave instead of re-applying the seed (which would clobber
// any edits they made).
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
url.hash = ''
window.history.replaceState(window.history.state, '', url.toString())
}
}
async function loadTemplate(): Promise<void> {
if (urlScript) return
if (templatePath) {
try {
const template = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path: templatePath
})
script = {
scriptHandle.draft = {
...defaultScript(),
summary: !emptyString(template.summary) ? `Copy of ${template.summary}` : '',
description: template.description,
@@ -99,7 +133,7 @@
path: template.path + '_fork'
}
} catch (err) {
script = defaultScript()
scriptHandle.draft = defaultScript()
console.error('Error loading template', err)
sendUserToast('Error loading template: ' + err.message, true)
}
@@ -107,12 +141,13 @@
}
async function loadHub(): Promise<void> {
if (urlScript) return
if (hubPath) {
try {
const { content, language, summary } = await ScriptService.getHubScriptByPath({
path: hubPath
})
script = {
scriptHandle.draft = {
...defaultScript(),
description: `Fork of ${hubPath}`,
content: replaceScriptPlaceholderWithItsValues(hubPath, content),
@@ -121,7 +156,7 @@
path: hubPath + '_fork'
}
} catch (err) {
script = defaultScript()
scriptHandle.draft = defaultScript()
console.error('Error loading script from hub', err)
sendUserToast('Error loading script from hub: ' + err.message, true)
}
@@ -131,11 +166,11 @@
loadHub()
let importedWacTemplate: 'wac_python' | 'wac_typescript' | undefined = undefined
if (importParam && $importScriptStore) {
if (!urlScript && importParam && $importScriptStore) {
const imported = $importScriptStore
$importScriptStore = undefined
const isWac = isWorkflowAsCode(imported.content ?? '', imported.language ?? '')
script = {
scriptHandle.draft = {
...defaultScript(),
...imported,
path: path ?? '',
@@ -157,7 +192,7 @@
})
</script>
{#if script}
{#if scriptHandle.draft}
<ScriptBuilder
{initialArgs}
bind:this={scriptBuilder}
@@ -178,9 +213,8 @@
}}
onNavigate={(item) => goto(editPathFor(item))}
searchParams={page.url.searchParams}
bind:script
bind:script={scriptHandle.draft}
{showMeta}
replaceStateFn={(path) => replaceState(path, page.state)}
>
<UnsavedConfirmationModal
getInitialAndModifiedValues={scriptBuilder?.getInitialAndModifiedValues}
@@ -4,38 +4,109 @@
import { initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { editPathFor, invalidate } from '$lib/components/workspacePicker'
import { decodeState, cleanValueProperties, orderedJsonStringify } from '$lib/utils'
import { cleanValueProperties, orderedJsonStringify } from '$lib/utils'
import { goto } from '$lib/navigation'
import { replaceState } from '$app/navigation'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import LocalDraftStaleModal from '$lib/components/common/confirmationModal/LocalDraftStaleModal.svelte'
import type { ScheduleTrigger } from '$lib/components/triggers'
import type { Trigger } from '$lib/components/triggers/utils'
import { get } from 'svelte/store'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft, checkStaleness, type UserDraftMeta } from '$lib/userDraft.svelte'
import { notifyRestoredFromLocal } from '$lib/userDraftToast'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
// `initialArgs` is intentionally captured once at mount — it's the
// session's initial argument set, not per-script. URL-derived state
// (`hash`, `topHash`, fragment autosave) is re-read inside `loadScript`
// because picker navigation reuses this component without remounting.
// session's initial argument set, not per-script.
let initialArgs = get(initialArgsStore) ?? {}
if (get(initialArgsStore)) $initialArgsStore = undefined
let script: (NewScript & { draft_triggers?: Trigger[] }) | undefined = $state(undefined)
let topHash = page.url.searchParams.get('topHash') ?? undefined
let initialPath: string = $state('')
let hash = page.url.searchParams.get('hash') ?? undefined
// When viewing a specific historical hash we don't want to load or write a
// local draft — that view is read-only relative to drafts.
const draftPath = hash ? '' : (page.params.path ?? '')
const scriptHandle = UserDraft.use<EditableScript>('script', draftPath)
/** Some pages base64-JSON-encode a NewScript-like payload into the URL
* hash on `/scripts/edit/<path>#…`. Treat it as a one-shot seed that
* wins over local autosave + backend draft + deployed: apply, toast,
* strip from the URL. Same logic as /scripts/add, kept in this file for
* a faithful mirror of its decoder.
*
* Can't reuse `decodeState` from utils.ts — it fires its own error toast
* on parse failure, which would noise up the UI for unrelated anchors.
*/
function decodeUrlScriptSeed(): Partial<EditableScript> | undefined {
const fragment = page.url.hash.startsWith('#') ? page.url.hash.slice(1) : ''
if (!fragment) return undefined
try {
const decoded = JSON.parse(decodeURIComponent(atob(fragment)))
if (decoded && typeof decoded === 'object') return decoded as Partial<EditableScript>
} catch {
// Hash isn't a valid encoded script — ignore.
}
return undefined
}
let urlScriptSeed = decodeUrlScriptSeed()
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`
// even when `scriptHandle.draft` is already defined synchronously from a
// local autosave. An empty initialPath flips ScriptBuilder's
// `metadataOpen` heuristic (intended for /scripts/add) into "true" and
// pops the settings drawer open on /edit.
let initialPath: string = $state(hash ? '' : (page.params.path ?? ''))
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
let reloadAction: () => Promise<void> = async () => {}
let savedScript: NewScriptWithDraft | undefined = $state(undefined)
let fullyLoaded = $state(false)
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. */
@@ -43,38 +114,143 @@
async function loadScript(): Promise<void> {
const tok = ++loadScriptToken
fullyLoaded = false
if (hash) {
const scriptByHash = await ScriptService.getScriptByHash({
workspace: $workspaceStore!,
hash
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
} else {
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptWithDraft))
// Re-read URL-derived state on every load. The component doesn't
// remount when the picker navigates between scripts, so capturing
// these at module init would leave them stale.
const urlFragment = window.location.hash != '' ? window.location.hash.slice(1) : undefined
const scriptLoadedFromUrl = urlFragment != undefined ? decodeState(urlFragment) : undefined
const hash = page.url.searchParams.get('hash') ?? undefined
const topHash = page.url.searchParams.get('topHash') ?? undefined
if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == page.params.path) {
script = scriptLoadedFromUrl
reloadAction = async () => {
goto(`/scripts/edit/${script!.path}`)
loadScript()
const localDraft = scriptHandle.draft
const previousMeta = scriptHandle.meta
const backendDraft = scriptWithDraft.draft
? ({ ...scriptWithDraft.draft } as EditableScript)
: undefined
const newRevs: UserDraftMeta = {
remoteRev: scriptWithDraft.hash,
remoteDraftRev: scriptWithDraft.draft_created_at
}
async function compareAutosave() {
const sf = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
path: script!.path
})
if (tok !== loadScriptToken) return
savedScript = sf
// Compute the fully-baked initial value once so the assignment
// below is a single write — otherwise post-load mutations like
// `parent_hash = ...` would count as a second write under
// useLocalStorageValue's saveInitialValue=false contract and get
// persisted before the user has touched anything.
const baseline = (backendDraft ?? (scriptWithDraft as EditableScript)) as EditableScript
const bakedBaseline: EditableScript = {
...baseline,
parent_hash: topHash ?? scriptWithDraft.hash
}
const draftOrDeployed = cleanValueProperties(savedScript?.draft || savedScript)
const urlScript = cleanValueProperties(scriptLoadedFromUrl)
if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) {
reloadAction()
if (urlScriptSeed) {
// URL hash seed wins over local autosave + backend draft + deployed.
// Wipe the stale autosave on disk so the in-memory seed becomes the
// only source — the user's subsequent edits will overwrite cleanly.
const seeded = { ...bakedBaseline, ...urlScriptSeed } as EditableScript
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(seeded, newRevs)
sendUserToast('Loaded from URL')
// Consume-once: strip the hash so a reload restores the user's
// autosave (which now reflects the seed + edits) instead of
// re-applying the seed.
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
url.hash = ''
window.history.replaceState(window.history.state, '', url.toString())
}
urlScriptSeed = undefined
} else if (localDraft != undefined) {
const reference = backendDraft ?? scriptWithDraft
const referenceClean = cleanValueProperties(reference)
const localClean = cleanValueProperties(localDraft)
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
// Local matches the saved version — silently drop it and use the saved one.
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
} else {
sendUserToast('Script loaded from latest autosave stored in the URL', false, [
const cause = checkStaleness(previousMeta, newRevs.remoteRev, newRevs.remoteDraftRev)
if (cause) {
// Remote moved on since the local autosave was written —
// surface the choice via modal. The local draft stays on
// screen until the user picks.
pendingBaseline = { baseline: bakedBaseline, revs: newRevs }
staleModalCause = cause
staleModalOpen = true
} else {
if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) {
// Legacy entry (no meta recorded) — backfill so future
// loads can detect staleness even if the user doesn't edit.
scriptHandle.setMeta(newRevs, { force: true })
}
const scriptPath = bakedBaseline.path
const hasBackendDraft = !!backendDraft
notifyRestoredFromLocal(hasBackendDraft, !scriptWithDraft.draft_only, {
onResetToSavedDraft: () => {
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
applyBaseline(bakedBaseline)
},
onResetToDeployed: async () => {
if (hasBackendDraft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: scriptPath
})
}
UserDraft.remove('script', draftPath)
// UserDraft.remove only clears localStorage. The entry's
// in-memory state is kept alive by this route's handle, so
// loadScript would re-read the stale autosave and the toast
// would fire again. Drop the in-memory state first.
scriptHandle.setDraftAndMeta(undefined, {})
goto(`/scripts/edit/${scriptPath}`)
loadScript()
}
})
}
}
} else if (backendDraft) {
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
if (bakedBaseline['primary_schedule']) {
savedPrimarySchedule = bakedBaseline['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
scriptBuilder?.setDraftTriggers(bakedBaseline.draft_triggers)
if (!scriptWithDraft.draft_only) {
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: bakedBaseline.path
})
UserDraft.remove('script', draftPath)
// UserDraft.remove only clears localStorage. The
// scriptHandle's in-memory state still holds the now-
// deleted DB draft + its meta — loadScript would treat
// it as a local autosave and the staleness check
// would fire a spurious "newer version was deployed"
// modal because remoteDraftRev moved from "defined"
// to "undefined". Drop the in-memory state first.
scriptHandle.setDraftAndMeta(undefined, {})
goto(`/scripts/edit/${bakedBaseline.path}`)
loadScript()
}
const deployed = cleanValueProperties(scriptWithDraft)
const draft = cleanValueProperties(bakedBaseline)
sendUserToast('Script loaded from latest saved draft', false, [
{
label: 'Discard browser stored autosave and reload',
label: 'Reset to deployed',
callback: reloadAction
},
{
@@ -83,96 +259,24 @@
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: draftOrDeployed,
current: urlScript,
title: `${savedScript?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: { text: 'Discard autosave', onClick: reloadAction }
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
}
compareAutosave()
} else {
if (hash) {
const scriptByHash = await ScriptService.getScriptByHash({
workspace: $workspaceStore!,
hash
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
script = { ...scriptByHash, parent_hash: hash, lock: undefined }
} else {
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptWithDraft))
if (scriptWithDraft.draft != undefined) {
script = scriptWithDraft.draft
scriptBuilder?.setDraftTriggers(script.draft_triggers)
if (script['primary_schedule']) {
savedPrimarySchedule = script['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
if (!scriptWithDraft.draft_only) {
reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: script!.path
})
goto(`/scripts/edit/${script!.path}`)
loadScript()
}
const deployed = cleanValueProperties(scriptWithDraft)
const draft = cleanValueProperties(script)
sendUserToast('Script loaded from latest saved draft', false, [
{
label: 'Discard draft reset to deployed version',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
script = scriptWithDraft
}
script.parent_hash = scriptWithDraft.hash
scriptHandle.setDraftAndMeta(bakedBaseline, newRevs)
}
}
// hash
// ? await ScriptService.getScriptByHash({
// workspace: $workspaceStore!,
// hash: page.params.hash
// })
// : await ScriptService.getScriptByPathWithDraft({
// workspace: $workspaceStore!,
// path: $page.params.path
// })
if (script) {
initialPath = script.path
scriptBuilder?.setDraftTriggers(script.draft_triggers)
scriptBuilder?.setCode(script.content)
if (topHash) {
script.parent_hash = topHash
}
if (scriptHandle.draft) {
initialPath = scriptHandle.draft.path
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
scriptBuilder?.setCode(scriptHandle.draft.content)
}
fullyLoaded = true
}
@@ -194,6 +298,12 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('script', draftPath)
// Drop the in-memory handle state so loadScript sees no local draft
// on the next pass — otherwise the staleness check would compare the
// stale in-memory meta against the freshly fetched backend and fire
// a spurious modal.
scriptHandle.setDraftAndMeta(undefined, {})
goto(`/scripts/edit/${savedScript.draft.path}`)
loadScript()
}
@@ -211,17 +321,25 @@
path: savedScript.path
})
}
UserDraft.remove('script', draftPath)
scriptHandle.setDraftAndMeta(undefined, {})
goto(`/scripts/edit/${savedScript.path}`)
loadScript()
}
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
{#if script}
<LocalDraftStaleModal
open={staleModalOpen}
cause={staleModalCause}
onLoadLatest={onStaleLoadLatest}
onKeepDraft={onStaleKeepDraft}
/>
{#if scriptHandle.draft}
<ScriptBuilder
bind:this={scriptBuilder}
{initialPath}
bind:script
bind:script={scriptHandle.draft}
{fullyLoaded}
bind:savedScript
{initialArgs}
@@ -229,6 +347,7 @@
{savedPrimarySchedule}
searchParams={page.url.searchParams}
onDeploy={(e) => {
UserDraft.remove('script', draftPath)
if ($workspaceStore) invalidate($workspaceStore, 'script')
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
@@ -240,9 +359,6 @@
goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`)
}}
onNavigate={(item) => goto(editPathFor(item))}
replaceStateFn={(path) => {
replaceState(path, page.state)
}}
>
<UnsavedConfirmationModal
{diffDrawer}