Compare commits

...
Author SHA1 Message Date
centdix eed01040a2 refactor(drafts): remove unused list_drafts endpoint and dev inspector 2026-06-08 17:21:21 +02:00
centdix 6d68924959 fix(drafts): guard empty storage path out of the draft DB seam 2026-06-08 16:35:58 +02:00
centdix c0c29dacde fix(drafts): propagate failed headless draft writes instead of swallowing 2026-06-08 15:22:04 +02:00
centdixandClaude Opus 4.8 259ff0a725 refactor(drafts): rename discard_local_draft to discard_draft and drop "local" copy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:22:04 +02:00
centdixandClaude Opus 4.8 05be02ffe5 fix(drafts): delete db draft immediately so a read-back can't resurrect it
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:22:04 +02:00
centdixandClaude Opus 4.8 b47fabc8fc feat(drafts): back global chat draft read/write with db layer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:22:04 +02:00
centdixandClaude Opus 4.8 b1d48fe858 fix(drafts): surface draft-only apps and path-prefixed draft-only rows
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:22:04 +02:00
centdixandClaude Opus 4.8 ac757bcd93 feat(copilot): list global chat drafts via db-backed draft flags
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:22:04 +02:00
Diego Imbert 7969679526 nit 2026-06-08 13:18:10 +02:00
Diego Imbert 32ca1c251e fix(drafts): drop the visibilitychange flush — debouncer keeps running on hidden tabs
Tab switching just hides the page; the JS context survives and the
debouncer's `setTimeout` keeps counting down. When it fires, the runner
POSTs normally and the server's response updates `lastSync`. There's
nothing left for a visibilitychange-driven flush to do that the
ordinary pipeline doesn't already handle, and adding one only creates
extra POSTs to reason about.

`pagehide` remains the single trigger for the keepalive flush — that's
the case where the JS context is actually being torn down and the
runner's pending fetch would otherwise be killed mid-flight.
2026-06-08 13:13:49 +02:00
Diego Imbert 289c0d6010 fix(drafts): split tab-switch and unload flushes — kill self-conflict on visibility change
The single keepalive flush bound to both `visibilitychange → hidden`
and `pagehide` self-conflicted on tab switch: visibilitychange fires
on every tab/app switch with the page still alive, the keepalive POST
advanced the server's `created_at` to a fresh `now()`, the client
discarded the response (no listener), the local `lastSync` stayed at
the old value, and the next foreground autosave sent that stale
timestamp → server saw `created_at > last_sync` → conflict modal for
the user's own background-tab write. A still-pending debouncer task
made it worse: it fired a second runner POST after the keepalive with
the same stale `last_sync`, the second self-conflicted too.

Split into two paths:

- `visibilitychange → hidden` → `flushOnVisibilityHidden`: route
  through the normal runner pipeline. The page is alive, so the
  response can land and `setLastSync` keeps the baseline current. Call
  `debouncer.cancel(key)` first so a queued keystroke can't double-fire
  with the same stale `last_sync`.

- `pagehide` → `flushOnPageHide`: keep the `keepalive: true` raw fetch
  for the genuinely-going-away case (the JS context is torn down, the
  response is necessarily discarded). Same `debouncer.cancel(key)`
  guard. On the next mount, the route's `recordRemoteSync(query,
  draft_saved_at)` reseeds `lastSync` from authoritative server state
  before any user edit can fire a save.
2026-06-08 11:33:36 +02:00
Diego Imbert 486f25f0d9 indicator ui nits 2026-06-08 11:29:34 +02:00
Diego Imbert 8865c58f66 fix(drafts): defer reset-to-deployed restart until first user interaction
Two-tick `restartSync` was too aggressive: editor remounts emit a tail
of cascading writes (Monaco setValue acks, schema re-infer, UI Builder
iframe handshakes, schedule-config recomputes, …) that land well after
two ticks and would clobber the just-deleted draft with an upsert of
the deployed value — making "Reset to deployed" a no-op in practice,
the user kept seeing the draft come back.

Centralise the suspension lifecycle in a new `runResetToDeployed`
helper. It stopSyncs around the reset, POSTs the explicit delete, runs
the route's wipe-and-reload, and then arms a one-shot listener on
document keydown / input / pointerdown that restartSyncs on the user's
next real interaction. A 5-second fallback re-arms sync if the user
walks away without touching the editor, so suspensions don't leak.

Use it from both the load-time toast (`notifyDraftLoaded`) and the
autosave-indicator popover so the two stay in sync — fixes both
entry points.
2026-06-08 01:45:34 +02:00
Diego Imbert 86f5ddb84d fix(drafts): conflict modal wording — drafts are user-scoped, not teammate-scoped 2026-06-08 01:37:33 +02:00
Diego Imbert abe660d778 fix(drafts): OtherUsersDraftsModal — close on Fork, don't leak clicks through nested JSON
Two bugs in the per-editor "another user has a draft" banner:

- Fork landed the immediate save but didn't close the banner before
  navigating. Svelte hadn't torn down the previous route's components
  by the time goto returned, so the banner lingered on top of the
  destination editor. Comment the explicit isOpen=false on the
  happy path so it's clear it MUST run before goto.

- Clicking anywhere on the screen while the View JSON drilldown was
  open closed the underlying banner too. Modal2's clickOutside
  action fired on every Modal2 instance — both the JSON modal and
  the underlying banner — because both attach their own listener at
  the document level. Add `closeOnOutsideClick` opt-out on Modal2
  and pass `closeOnOutsideClick={!jsonOpen}` to the outer modal so
  clicks outside the JSON drilldown only close the drilldown.

Drive-by: Modal2's keydown handler now ignores Escape when its own
isOpen is false (was a no-op closer that would still preventDefault
on every key press, swallowing key events for any siblings).
2026-06-08 01:36:14 +02:00
Diego Imbert 9c8c4edb12 fix(drafts): conflict detection — keep last_sync map tab-local instead of in localStorage
Two tabs editing the same draft both load with last_sync = T0.
Tab-1 saves; the server accepts, returns T1, and the syncer wrote T1
into localStorage. Tab-2 then tries to save: it reads the SHARED
localStorage map, sees T1 instead of its own baseline T0, sends
last_sync = T1, and the backend's WHERE clause (`created_at <=
last_sync`) is true → tab-2 clobbers tab-1's edit without ever seeing
a conflict.

Move the map to tab-local memory (`new Map<string, …>`). Reload of the
tab now starts with an empty map; that's fine because the editor's
load path calls `recordRemoteSync(query, draft_saved_at)` right after
`get_draft=true` returns, reseeding from the authoritative server
timestamp before any user edit could fire a save.
2026-06-08 01:32:33 +02:00
Diego Imbert ef33a833b8 fix(drafts): wait for the fork POST to land before navigating
OtherUsersDraftsModal's Fork action called UserDraft.save, which routes
through the autosave debouncer (1500ms). The subsequent goto fired
within the same tick, so the destination editor's get_draft=true read
ran before the POST landed and 404'd — refreshing worked because by
then the debounced save had fired.

Call UserDraftDbSyncer.save with immediate: true and await it. The
syncer cancels any queued debouncer task for the key and resolves the
promise only after the POST completes, so the route load can find the
forked draft on the first try.
2026-06-08 01:30:18 +02:00
Diego Imbert 22f2bae8c7 feat(drafts): autosave-indicator popover with Reset-to-deployed action
Click the cloud icon → popover with "All changes are saved as a draft on
the server. The draft is per-user — your teammates' editors keep their
own." When the editor isn't on a draft-only path AND the user has a
draft (UserDraft.has returns true), a "Reset to deployed" button
mirrors the load-time toast action — stops sync, POSTs `value: null`,
runs the route's reload-without-draft callback, restarts sync past two
ticks so the deployed-seed write doesn't resurrect the draft.

Threaded `onResetToDeployed` from each route down to its builder
(ScriptBuilder / FlowBuilder / AppEditorHeader / RawAppEditorHeader)
and into the indicator. `draftOnly` is wired from `savedScript.no_deployed`
/ `newFlow` / `newApp` so the action hides where there's nothing to fall
back to. The indicator's trigger now has a hover affordance + matches
Portal's default target ('body') via Modal2's earlier fix.
2026-06-08 01:26:30 +02:00
Diego Imbert c0bf4539bb ui nit 2026-06-08 01:24:31 +02:00
Diego Imbert b646f7929f fix(drafts): Reset to deployed no longer resurrects the draft
The toast's "Reset to deployed" callback POSTed `value: null` to the
syncer, then handed control to the route's `onResetToDeployed` (which
wipes the in-memory handle and reloads the deployed payload via
`getDraft: false`). Both writes flowed through the reactive sync
effect: the wipe scheduled a delete, the reload scheduled a re-save of
the deployed value as the new draft. Coalescing collapsed them and the
draft came back — making the "discard" action effectively a no-op.

Wrap the whole callback in `UserDraft.stopSync` / `restartSync`. The
explicit `value: null` POST still goes through (it's a direct
`UserDraftDbSyncer.save` that doesn't depend on the reactive effect),
the route's wipe-then-reload mutations advance `lastSerialized` silently
under suspension, and the next user edit (after two ticks past the
deployed-seed write) is the first real save again.
2026-06-08 01:15:47 +02:00
Diego Imbert 9890a55306 fix(ui): default Modal2 target to 'body' so omitting the prop doesn't throw
Modal2 defaulted `target = ''` and forwarded it to `Portal`, which calls
`document.querySelector(target)` — an empty selector throws
"Failed to execute 'querySelector' on 'Document': The provided selector
is empty" and the modal silently fails to mount.

That's why `OtherUsersDraftsModal` (and `DraftSyncConflictModal`) never
appeared on editors where another user had a draft — both omit the
`target` prop. Other Modal2 callers (StorageSettings, CriticalAlert,
CustomInstanceDbWizardModal, …) pass an explicit `target="#content"`
and were unaffected.

Match Portal's own default of `'body'` so omitting the prop is now a
no-op rather than a runtime throw.
2026-06-08 01:12:18 +02:00
Diego Imbert 3290b80817 fix(drafts): preserve the user-typed draft_path on reload of draft-only items
The flow / app / raw-app editors all dropped the saved `draft_path`
back to the URL's `u/{user}/draft_{uuid}` slot the moment the user
reloaded a draft-only edit page: the route sourced the Path widget's
initial path from `page.params.path` instead of the previously-saved
`draft_path`, and the first user edit then mirrored that URL path
back into the autosaved draft — silently overwriting the friendly
name in both the row and the editor.

- Flow route: after computing `effectiveFlow`, override `flowInitialPath`
  with `effectiveFlow.draft_path` when set.
- App route: pass `newPath={(app.value as any)?.draft_path ?? app.path}`
  through to `AppEditor`; AppEditorHeader's `newEditedPath` default now
  prefers a non-empty `newPath` over the random `<adj>_app` seed (the
  `newApp && !newPath` branch keeps the `/apps/add` friendly auto-name).
- Raw-app route: surface `savedRawAppDraft.draft_path` onto `backendApp`
  so the `extractRawApp` path seeds `newPath` with the friendly name.

Reload + a subsequent edit now leaves `draft_path` intact for all three
kinds; verified end-to-end via the `/drafts/get_draft/...` endpoint.
2026-06-08 01:03:48 +02:00
Diego Imbert 47c45edd80 feat(drafts): render friendly user-typed path on home list for all 4 kinds
Reinstate `draft_path` on `Listable{Script,Flow,App}` so the home rows
prefer the user-typed name over the autogenerated `u/{user}/draft_{uuid}`
URL slot, with two source rules — one per how each editor wires the
Path widget:

- Scripts already work: `ScriptBuilder` binds the Path widget directly
  to `script.path`, so the typed path round-trips through the draft
  JSON's own `path` field. Backend extracts `v["path"]` when it differs
  from `row.path`.

- Flows / apps / raw apps don't write the typed path into the
  autosaved value (`Flow.path` is one-way-bound to `$pathStore`; the
  bare `App` / raw-app value has no `path` field at all). Introduce an
  explicit `draft_path` field on the draft JSON, written by the editor
  ONLY when the typed path differs from the deployed/seeded
  `savedX.path`:
  - FlowBuilder: $effect on `$pathStore` mutates `flow.draft_path`.
  - AppEditorHeader: $effect on `newEditedPath` mutates `$app.draft_path`.
  - RawAppEditorHeader: $effect surfaces `pendingDraftPath` up via the
    bind chain (RawAppEditor → route); the route's draftHandle.draft
    spread includes `draft_path` when set.
  Backend extracts `v["draft_path"]` and `None` when unchanged or after
  deploy (deploy clears the whole draft, so the field naturally
  disappears post-deploy without bookkeeping).

Flow route's `new_draft` branch now stops sync around the Path widget
cascade, with a 700ms scheduled `restartSync` (mirrors the existing
scripts/apps/raw_apps stoppers) — the new draft_path mutation lands
inside that window so `/flows/add` no longer fires an autosave before
the user's first edit. openapi/sqlx regenerated.
2026-06-08 00:54:11 +02:00
Diego Imbert 0ffcb13080 fix(drafts): seed a friendly name on /flows/add
The flow route passed `initialPath={page.params.path ?? ''}` to
FlowBuilder, so on the `/flows/add → /flows/edit/u/{user}/draft_{uuid}`
redirect the Path widget's `initPath` saw a non-empty `initialPath` and
skipped the `reset()` branch that auto-generates the friendly
`<random_adj>_flow` name. The other three editors all clear
`initialPath` in their `new_draft` branch for exactly this reason.

Track `initialPath` as route-owned state (defaults to the URL path) and
clear it to '' inside the `new_draft` branch, then bind it through to
FlowBuilder so any post-deploy update from the editor still propagates.
2026-06-08 00:25:57 +02:00
Diego Imbert 41056473e1 refactor(drafts): drop dead draft_path field from list responses
The draft-only listing branches in scripts/flows/apps computed a
`draft_path` from the draft JSON (when the user-typed path differed from
the URL's autogenerated `u/{user}/draft_{uuid}`), and `{Script,Flow,App}
Row.svelte` preferred it over `path` for the row title. In practice
that path is never written: the app, raw-app and flow editors all warn
"Deploy the X to make the path change effective" — the rename only
lands on deploy, never in the draft. So the field is always None and
the home rows always show the autogenerated slot anyway.

Drop the field from the three `Listable*` structs, the three draft-only
push sites, the three OpenAPI response schemas, and the three frontend
row components. Client regenerated.
2026-06-08 00:18:56 +02:00
Diego Imbert 00a28d5eec fix(drafts): disable the "No login required" toggle on draft-only apps
Flipping the toggle called `setPublishState`, which POSTs the new
`policy` through `AppService.updateApp` — that handler's
`UPDATE app ... RETURNING path` finds nothing on a draft-only path
and `not_found_if_none` 404s with "App not found at name …"
(apps.rs:1975). Gate the Toggle on `!newApp` too so the user has to
deploy once before configuring the publish state.
2026-06-07 23:55:30 +02:00
Diego Imbert 6c758ef3ff fix(drafts): disable Diff button on draft-only items across the 4 editors
Diff has no baseline to compare against on draft-only items — the
button used to be gated by the pre-PR `/add` route's own state, but the
`/add → /edit` redirect landed everything under the regular `/edit`
page where the gate was missing.

- ScriptBuilder: gate the topbar Diff on `savedScript.no_deployed`;
  seed `no_deployed: true` on the route's `new_draft` empty NewScript
  so the gate fires before the first deploy.
- FlowBuilder: gate the topbar Diff on `newFlow` (route already sets
  it from `backendFlow.no_deployed` and the new-draft branch).
- AppEditorHeader: gate both the "Diff" dropdown action and the
  Deploy-drawer's "Diff" button on `newApp`.
- RawAppEditorHeader: gate the topbar Diff + the Deploy-drawer's "Diff"
  button on `newApp`.

Each gate also rewrites the tooltip ("Deploy this … once to compare
against the deployed version") so the hover state explains why.
2026-06-07 23:54:26 +02:00
Diego Imbert 5f94207c02 fix(drafts): skip public-secret-URL fetch in the Deploy drawer for draft-only apps
Opening the Deploy drawer on a `/edit/u/{user}/draft_{uuid}` app fired
`AppService.getPublicSecretOfApp` immediately because the gating effect
only checked `appPath != ''` + `savedApp`. The `/secret_of/{path}` route
plain-SELECTs `app.id`, so a draft-only path 404'd with
"App not found at name …" and the public-URL ClipboardPanel spun
forever waiting on `secretUrl`.

Thread the existing `newApp` signal (already on `AppEditorHeader` /
`RawAppEditorHeader`) into `AppEditorHeaderDeploy`, gate the fetch
behind `!newApp`, and render the existing "Deploy this app once to get
the public secret URL" placeholder instead of the spinner for
draft-only items.
2026-06-07 23:49:50 +02:00
Diego Imbert 131eb8078d fix(drafts): render the right shape in View/Edit JSON for draft-only items
The previous fix landed `fapp.value` into the editor, but the
deployed-overlay flattens the bare editable shape into `inner`/the
top-level response — drafts have no nested `.value`. So:

  - App drafts (`{grid, breakpoints, hiddenInlineScripts, …}`) rendered
    as empty (`fapp.value` was undefined).
  - Raw-app drafts 404'd outright: `get_draft=true` with no `rawApp` flag
    can't tell which draft kind to look up, defaults to `app`, doesn't
    find one.

Thread the row's `raw_app` flag from AppRow → `appExport.open(path,
rawApp)` → `getAppByPath({..., rawApp})` so raw-app drafts resolve to
the right `UserDraftItemKind`. Read `fapp.draft` (the bare editable
shape from `fetch_draft_only`) into the JSON editor for draft-only
items — clean payload, no `is_draft` / `no_deployed` / overlay noise.
Save the same bare shape back through the syncer so the regular
editor reads it unchanged on the next mount.
2026-06-07 23:44:33 +02:00
Diego Imbert e21d3e0c5f fix(drafts): make the home-page View/Edit JSON action work on draft-only apps
The "View/Edit JSON" entry on the home page called `AppService.getAppByPath`
without `get_draft=true`, so for draft-only items at `u/{user}/draft_{uuid}`
the backend 404'd with "App not found at path …". Pass `get_draft=true`
and render the synthesized stand-in's editable shape:

- App drafts come back as `{summary, value, path, policy, ...}` — `value`
  is the App definition the editor was working on; show that.
- Raw-app drafts come back as the flattened
  `{files, runnables, data, summary, policy, ...}` with no nested `value`;
  show the whole shape.

On save, draft-only items can't go through `updateApp` (no deployed row).
Route the edit through `UserDraftDbSyncer.save` (with `immediate: true`
so `await` resolves after the POST lands) and relabel the button
"Save draft" + Save icon. Deployed items keep the existing "Deploy"
flow unchanged.
2026-06-07 23:36:27 +02:00
Diego Imbert 05b61b7e80 nit unused 2026-06-07 23:32:26 +02:00
Diego Imbert f08a3d92f8 fix(raw_app): propagate template picker X / Esc dismissal so autosave resumes
The picker mounted `<Modal kind="X" open ...>` (one-way prop, not
`bind:open`). When the user dismissed via X / Esc / click-outside, the
inner Modal flipped its own local `open` to false (hiding the UI) but
never wrote back to the picker's `open` $bindable. The route's
`templatePicker → false` watcher — the one that calls `restartSync`
two ticks after the picker closes — never fired, so autosave stayed
suspended and the user's edits after dismissal were silently dropped.

Switch the inner Modal to `bind:open` so the dismissal bubbles all the
way up to the route's state. "Start without AI" already worked because
its `onStart` handler explicitly sets the picker's `open = false`.
2026-06-07 23:27:56 +02:00
Diego Imbert cfa4c8eeff fix(drafts): key low-code app autosave on the URL path, not the empty string
`AppEditor` keyed its `UserDraft.use` handle on `newApp ? '' : path` —
a legacy leftover from when `/apps/add` was its own URL (no path). With
the `/add` ⇒ `/edit/u/{user}/draft_{uuid}` redirect, `newApp=true` made
autosaves land on the `('app', '')` row instead of the URL path:
  - The `apps/list?include_draft_only=true` query joins drafts onto
    `app.path`, surfacing drafts at the URL path. The empty-path row
    didn't match the user's URL so the draft never appeared in the home
    list.
  - Refreshing `/apps/edit/u/{user}/draft_{uuid}` re-fetches at the URL
    path with `?get_draft=true`, finds nothing, and 404s.

Drop the ternary so the handle always uses `path` — the same as
scripts/flows/raw_apps. The route's `?new_draft=true` branch already
seeds the empty-template baseline, so there's no longer a "the
draft sits under '' until first save" race to worry about.
2026-06-07 22:05:57 +02:00
Diego Imbert 0e5c2310d0 fix(drafts): land /add redirects on the real workspace username, not "me"
The `/add` → `/edit/u/{username}/draft_{uuid}` redirects ran during
SvelteKit's load phase, BEFORE the (logged) layout's async `getUserExt`
populated `userStore`. `get(userStore)?.username` returned undefined and
fell back to the `'me'` placeholder on every fresh nav, producing
`u/me/draft_{uuid}` paths instead of the user's real namespace — broke
ownership checks against `authed.username` and silently scoped autosaves
under the wrong path.

Layout now persists `username` to localStorage on every successful
`getUserExt`, and `getUsernameForNamespace` (new shared helper, used by
all four `/add/+page.ts` files) reads the live store first, falls back
to the cached value, and only then to `'me'` for true first-ever loads.
2026-06-07 17:45:30 +02:00
Diego Imbert 9a4bab057b fix(drafts): suppress autosave during /add template seeding on script + raw app editors
- ScriptBuilder: delay `restartSync` 500ms past `initContent` + stores-
  ready so the Path widget's `$workspaceStore && $userStore`-gated
  `initPath → reset → onMetaChange → bind:path` cascade lands inside
  the suspension window. Two `tick()` waits weren't enough — the
  bind:path mutation fired ~100ms after the prior `restartSync` and
  posted as a "user edit".
- apps_raw route: suspend autosave on `new_draft=true` and resume only
  after the framework picker closes (via `onStart` or X dismissal),
  with a two-tick settle so the picker's seeded
  `files/runnables/data/policy` mirror to `draftHandle.draft` observably
  advances `lastSerialized` before sync re-arms.
2026-06-07 17:41:36 +02:00
Diego Imbert 038b3da834 feat(drafts): collaboration banner, cross-tab conflict detection, raw app template picker
- Other-users-drafts banner (Modal2): the deployed-overlay response now
  carries `other_drafts_users` (workspace usernames only, never emails);
  each row offers View JSON + Fork. Drops the standalone
  `listUsersWithDraftOnPath` endpoint; `getDraftForUser` now takes a
  workspace `username` query param (resolved to email server-side).
- Cross-tab/browser save conflict detection: the syncer attaches
  `last_sync` to every save (defaults to non-force); on a `conflict`
  response it parks a snapshot in a reactive map. Each route mounts a
  `DraftSyncConflictModal` and seeds the per-tab `last_sync` via
  `recordRemoteSync(query, draft_saved_at)` on every `get_draft` load.
  Keepalive flush also respects optimistic concurrency.
- Raw app template picker re-added after the /add ⇒ /edit refactor:
  framework (React 19 / 18 / Svelte 5), data table + schema config, and
  optional AI prompt — extracted into `RawAppTemplatePicker.svelte` and
  driven by `new_draft=true` on the edit route.
2026-06-07 11:29:50 +02:00
Diego Imbert f4a232baa4 refactor(editors): drop UnsavedConfirmationModal mount + Show diff button 2026-06-07 11:29:26 +02:00
Diego Imbert 87573f85e9 Merge remote-tracking branch 'origin/main' into remove-workspace-drafts 2026-06-07 01:20:16 +02:00
Diego Imbert 99f5a893a5 autosave indicator 2026-06-06 22:54:54 +02:00
Diego Imbert f65a06380b fix: heal legacy drafts with schema={} (no .properties) on deploy 2026-06-06 19:53:21 +02:00
Diego Imbert 7bf9a1e7a7 fix: seed new-draft script schema as emptySchema() so inferArgs doesn't trip on undefined properties 2026-06-06 19:38:09 +02:00
Diego Imbert 98df2b7552 revert: drop unsuccessful path-stabilization heuristics + leftover [draft-sync] logs 2026-06-05 22:32:02 +02:00
Diego Imbert 9bf6a7dbc2 fix: wait for script.path to stabilize across two ticks before restartSync 2026-06-05 22:26:06 +02:00
Diego Imbert a7320f0fae chore: log inferArgs underlying error on deploy to diagnose 'Could not parse code' toast 2026-06-05 22:20:44 +02:00
Diego Imbert 724e27003e fix: poll script.path via tick() until Path widget settles before restartSync 2026-06-05 22:19:06 +02:00
Diego Imbert e4453e642c fix: defer script restartSync until script.path lands (Path widget gated on $userStore + $workspaceStore) 2026-06-05 21:32:20 +02:00
Diego Imbert 75b8a08ce4 fix: seed auto-generated path in script new-draft route to suppress Path widget's autosave-triggering mutation 2026-06-05 20:56:04 +02:00
Diego Imbert fbe0552cdd chore: add [draft-sync] console logs to trace script bootstrap autosave 2026-06-05 20:51:00 +02:00
Diego Imbert 479b255857 fix: route create-vs-update on /add deploys; seed policy.execution_mode; sync script template 2026-06-05 20:02:31 +02:00
Diego Imbert c1aba2754d autosave indicator nits 2026-06-05 19:23:39 +02:00
Diego Imbert cca0e67054 feat: flush pending draft saves via keepalive fetch on tab hide / pagehide 2026-06-05 19:18:00 +02:00
Diego Imbert 75e2d99e44 fix: gate per-user draft-only rows in listings on include_draft_only flag 2026-06-05 01:38:40 +02:00
Diego Imbert bb60641ff6 refactor: drop draft-loaded toast in non-route editors, banner now compares draft vs deployed 2026-06-05 01:36:10 +02:00
Diego Imbert 41001aa9fc feat: add AutosaveIndicator backed by reactive UserDraftDbSyncer.getState 2026-06-04 20:24:22 +02:00
Diego Imbert 8ddb2df876 fix: also stopSync in route's new_draft branch + queue pre-acquire suspensions 2026-06-04 19:12:39 +02:00
Diego Imbert ae1652bff3 fix: thread URL path into ScriptBuilder.stopSync (was using empty initialPath) 2026-06-04 18:14:01 +02:00
Diego Imbert 1709f6978c feat: add UserDraft.stopSync/restartSync, wire on script + low-code app /add init 2026-06-04 16:18:58 +02:00
Diego Imbert 4110abcb77 feat: surface draft path in home list when user typed one different from URL 2026-06-04 16:12:24 +02:00
Diego Imbert 67a8654220 refactor: send draft as separate .draft field instead of deep-merging onto deployed 2026-06-04 16:03:04 +02:00
Diego Imbert 99439809f7 fix: drop +page.js files in /add routes that conflicted with +page.ts 2026-06-04 15:45:03 +02:00
Diego Imbert adcaee889f fix: redirect /add routes at load phase to eliminate white flash 2026-06-04 15:43:27 +02:00
Diego Imbert 8972ceb07f fix: seed UserDraft cell from spec defaultValue on acquire 2026-06-04 15:38:27 +02:00
Diego Imbert 9f80974e25 feat: add immediate-save bypass that cancels pending debouncer + runner tasks 2026-06-04 15:32:36 +02:00
Diego Imbert 17cacd67d2 feat: route UserDraftDbSyncer.save through debouncer + coalescing runner 2026-06-04 15:21:19 +02:00
Diego Imbert d0c464e47c fix: bridge saved-draft shape to wire shape in apps/resources/variables loaders 2026-06-04 15:14:44 +02:00
Diego Imbert 1ecc8411e4 refactor: drop await on draft-delete in reset flows, refetch deployed directly 2026-06-04 15:03:09 +02:00
Diego Imbert 8ceb71dcdc createDebouncerByKey 2026-06-04 12:48:12 +02:00
Diego Imbert 74f1206969 example ts doc 2026-06-04 12:47:54 +02:00
Diego Imbert 1a2bf61f45 createCoalescingKeyedRunner 2026-06-04 12:46:37 +02:00
Diego Imbert 589afb0f0b feat: hide 'Reset to deployed' action when no deployed version exists 2026-06-03 10:23:36 +02:00
Diego Imbert 4ebac0ee4c feat: add 'Reset to deployed' action on draft-loaded toast 2026-06-03 10:12:28 +02:00
Diego Imbert 79cb813be7 fix: migrate session runtime + script view to per-user draft API 2026-06-03 09:51:01 +02:00
Diego Imbert cff286bd15 feat: migrate localStorage drafts to DB on layout mount 2026-06-02 18:37:29 +02:00
Diego Imbert fa71fceb8c refactor: drop vestigial LS-era code from UserDraft 2026-06-02 18:32:36 +02:00
Diego Imbert 34f68cbb0e refactor: drop localStorage layer from UserDraft 2026-06-02 17:55:36 +02:00
Diego Imbert 938c2d5336 fix: remove URL-hash sync from script editor (already marked TEMP) 2026-06-02 17:08:02 +02:00
Diego Imbert a6091d91cd fix: skip first observable change in DB sync effect to match LS persist 2026-06-02 14:47:13 +02:00
Diego Imbert a0273f8152 fix: tolerate missing latest-version on draft-only flow reload 2026-06-02 14:46:37 +02:00
Diego Imbert b7d8032134 fix: synthesize value wrapper on draft-only raw_app response 2026-06-02 14:42:20 +02:00
Diego Imbert 859bee78c0 feat: re-add Draft and Draft only badges on home page rows 2026-06-02 14:39:53 +02:00
Diego Imbert 1d940311d0 fix: empty path seed on new_draft so friendly auto-name fires 2026-06-02 14:32:06 +02:00
Diego Imbert dd5028da48 feat: delete user drafts when their underlying item is deleted 2026-06-02 14:22:23 +02:00
Diego Imbert 69d5aa6db9 fix: route draft-only deletes through UserDraftDbSyncer on home page 2026-06-02 14:16:48 +02:00
Diego Imbert d6e8ffc988 fix: prefix draft paths with u/{user} and seed editor state on new_draft 2026-06-02 13:45:59 +02:00
Diego Imbert 715849886a feat: include user drafts in list endpoints with is_draft flag 2026-06-02 13:39:47 +02:00
Diego Imbert 84fc8f4367 fix: drop dangling nobackenddraft assignment in flows edit 2026-06-02 12:35:41 +02:00
Diego Imbert 9e6053adf5 fix: inline get_draft query field instead of flattening 2026-06-02 12:34:25 +02:00
Diego Imbert 32a78be6dc feat: redirect /add pages to /edit/draft_uuid with new_draft flag 2026-06-02 12:29:47 +02:00
Diego Imbert 00e195c1ba readLastSyncMap 2026-06-01 20:30:34 +02:00
Diego Imbert af83a7bd50 Merge remote-tracking branch 'origin/main' into remove-workspace-drafts 2026-06-01 18:22:44 +02:00
Diego Imbert d8d3a50869 feat: support null value in save_draft for deletes 2026-06-01 16:55:45 +02:00
Diego Imbert cc66ecd45b feat: extend get_draft overlay to flow, app, resource, variable, schedule, triggers 2026-06-01 15:43:55 +02:00
Diego Imbert b36cb76139 feat: add get_draft overlay to getScriptByPath 2026-06-01 15:06:28 +02:00
Diego Imbert 2314f4719f feat: add save_draft, list_drafts, get_draft routes 2026-06-01 14:28:54 +02:00
Diego Imbert bea1dfaaea refactor: remove draft sync layer and conflict modal 2026-06-01 13:15:05 +02:00
Diego Imbert 549c0926a1 pushDrafts 2026-05-31 09:10:08 +02:00
Diego Imbert f7d6da91cf remove queuing logic 2026-05-29 16:46:34 +02:00
Diego Imbert 206abab148 Merge remote-tracking branch 'origin/main' into remove-workspace-drafts 2026-05-29 15:49:21 +02:00
Diego Imbert 80d596d1cd Rollback UserDraft 2026-05-29 15:40:17 +02:00
Diego Imbert c7dea4df44 fix(userdraft): trigger sync on deep mutations via readFieldsRecursively 2026-05-29 09:12:36 +02:00
windmill-internal-app[bot] 1fda953b79 chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd
This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private.

Previous ee-repo-ref: 55c19293232be379a3044eb78f677b545882ffd6

New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd

Automated by sync-ee-ref workflow.
2026-05-28 16:07:11 +00:00
Diego Imbert 5f8ac6b6a3 Merge remote-tracking branch 'origin/main' into remove-workspace-drafts
# Conflicts:
#	frontend/src/lib/components/copilot/chat/global/core.test.ts
2026-05-28 17:33:30 +02:00
Diego Imbert 7f35f3a53f perf: add (workspace_id, email, created_at) partial index for sync hot path 2026-05-28 17:14:09 +02:00
Diego Imbert b236f93a90 refactor: unify draft schema migrations and type kinds via DRAFT_KIND enum 2026-05-28 16:51:30 +02:00
Diego Imbert 9a20bebff4 feat: surface other users' drafts in editors with diff+fork action 2026-05-28 16:12:59 +02:00
Diego Imbert b44f031d35 feat: support draft deletion via sync (value: null) with same conflict semantics 2026-05-28 16:02:02 +02:00
Diego Imbert 71e7c58838 refactor: route draft permission check through authed.folders + RLS, drop client-supplied email 2026-05-28 12:00:13 +02:00
Diego Imbert ff576901a3 feat: sync all userdraft kinds, switch draft owner to email FK, add id PK, scope draft list to readable paths 2026-05-28 11:28:06 +02:00
Diego Imbert ce0eb0a23b refactor: move sync force flag from request-level to per-entry 2026-05-28 11:17:29 +02:00
Diego Imbert dcf60a933c refactor: gate useLocalStorageValue nested-update effect behind opt-in flag 2026-05-28 11:15:24 +02:00
Diego Imbert 5bb0837baf feat: wire UserDraft.save through DbSyncer + conflict modal 2026-05-28 00:35:26 +02:00
Diego Imbert 1f40a8b787 feat: add UserDraftDbSyncer service for bi-directional draft sync 2026-05-28 00:29:58 +02:00
Diego Imbert 690741a5b2 feat: add sync_drafts and list_users_with_draft_on_path endpoints 2026-05-28 00:26:42 +02:00
Diego Imbert 39b9d8f9b8 feat: add username column to draft table for user-scoped drafts 2026-05-28 00:22:36 +02:00
Diego Imbert 4a41444a7e fix: merge backend defaults into legacy autosaves to avoid spurious restore toast on raw apps 2026-05-27 23:26:52 +02:00
Diego Imbert 10d4794b7d fix: remove nodraft from all edit links 2026-05-27 23:11:24 +02:00
Diego Imbert 3b446160b8 fix: remove nodraft from app and raw app edit buttons 2026-05-27 21:08:42 +02:00
Diego Imbert f784beea7b fix: remove nodraft from flow row edit link 2026-05-27 21:07:36 +02:00
Diego Imbert e8db15e5d7 refactor: drop unsaved-changes confirmation modal from editors 2026-05-27 21:04:25 +02:00
Diego Imbert 0fa5d61d69 Db draft removal 2026-05-27 16:36:44 +02:00
131 changed files with 7459 additions and 6492 deletions
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "id",
"name": "id!",
"type_info": "Uuid"
}
],
@@ -16,7 +16,7 @@
]
},
"nullable": [
false
null
]
},
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "now!",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"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"
]
}
}
},
"Timestamptz",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4"
}
@@ -0,0 +1,63 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at as \"saved_at!\"\n FROM draft\n WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 1,
"name": "saved_at!",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"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"
]
}
}
}
]
},
"nullable": [
false,
false
]
},
"hash": "4267fd249d29d0df6d1b1132bc04dc915265fbacd1e344a641f1bcceb7d9634e"
}
@@ -0,0 +1,57 @@
{
"db_name": "PostgreSQL",
"query": "SELECT u.username as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username?",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"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"
]
}
}
},
"Text"
]
},
"nullable": [
false
]
},
"hash": "4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path,\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND email = $2\n AND ($3::text IS NULL OR path LIKE $3 || '%')\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "697c8c8794a4ad6aece92810319dc37404e9d6bb598842d3c7f846c2e50457f0"
}
@@ -0,0 +1,42 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path,\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND email = $2\n AND ($3::text IS NULL OR path LIKE $3 || '%')\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "typ!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "86a7ff7c5f178844007b6e66ca0fe47c26034d500c35dad5442bdc14bf8d1aa8"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT now() as \"now!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "now!",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5"
}
@@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"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"
]
}
}
}
]
},
"nullable": []
},
"hash": "a15ff27845c017aa201210147f6a4fd63a2be3646aac508214cbfab065f3328c"
}
@@ -13,4 +13,4 @@
"nullable": []
},
"hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270"
}
}
@@ -0,0 +1,63 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND email IS NOT DISTINCT FROM $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"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"
]
}
}
},
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9"
}
@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, now())\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"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"
]
}
}
},
"Text",
"Timestamptz",
"Bool"
]
},
"nullable": [
false
]
},
"hash": "e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545"
}
@@ -0,0 +1,63 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"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"
]
}
}
}
]
},
"nullable": [
false,
false
]
},
"hash": "ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa"
}
@@ -0,0 +1,57 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_at FROM draft\n WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"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"
]
}
}
}
]
},
"nullable": [
false
]
},
"hash": "ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path,\n value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND email = $2\n AND ($3::text IS NULL OR path LIKE $3 || '%')\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
"type_info": "Json"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "f8e8761da49f33e9cf7d73586757e7cc4515081955b8850bebc4ee817819e71a"
}
@@ -0,0 +1,21 @@
ALTER TABLE draft DROP COLUMN id;
DROP INDEX IF EXISTS draft_user_sync_idx;
DROP INDEX IF EXISTS draft_pkey_legacy;
DROP INDEX IF EXISTS draft_pkey_with_user;
-- Per-user rows can't be represented in the pre-sync schema (one row per
-- (workspace_id, path, typ)). Drop them before restoring the composite PK.
DELETE FROM draft WHERE email IS NOT NULL;
ALTER TABLE draft ADD CONSTRAINT draft_pkey PRIMARY KEY (workspace_id, path, typ);
ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey;
ALTER TABLE draft DROP COLUMN email;
-- Restore the narrower DRAFT_TYPE enum (script/flow/app only). Drop any
-- rows whose kind falls outside that set so the cast doesn't fail.
CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app');
DELETE FROM draft WHERE typ::text NOT IN ('script', 'flow', 'app');
ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_TYPE USING typ::text::DRAFT_TYPE;
DROP TYPE DRAFT_KIND;
@@ -0,0 +1,73 @@
-- Reshape `draft` for per-user bidirectional sync:
-- * add `email` (FK to `password.email`) — owner of the draft. NULL on
-- legacy rows written before per-user sync existed.
-- * replace the composite PK with two partial unique indexes so per-user
-- rows and the single legacy workspace-level row can coexist at the
-- same (workspace_id, path, typ).
-- * replace the DRAFT_TYPE enum (script/flow/app only) with DRAFT_KIND,
-- covering every UserDraftItemKind the sync layer accepts. Keeping it
-- as an enum (rather than VARCHAR) lets the type system reject typos
-- at the DB boundary and stays in sync with the Rust `UserDraftItemKind`.
-- * give the table a synthetic BIGSERIAL `id` PK — tools that assume a
-- real PK (pg_dump, replication, ORM drift detection) break on the
-- partial-index-only layout above.
CREATE TYPE DRAFT_KIND AS ENUM (
'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'
);
ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_KIND USING typ::text::DRAFT_KIND;
DROP TYPE DRAFT_TYPE;
ALTER TABLE draft ADD COLUMN email VARCHAR(255);
ALTER TABLE draft
ADD CONSTRAINT draft_password_fkey
FOREIGN KEY (email)
REFERENCES password(email)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE draft DROP CONSTRAINT draft_pkey;
CREATE UNIQUE INDEX draft_pkey_with_user
ON draft (workspace_id, path, typ, email)
WHERE email IS NOT NULL;
CREATE UNIQUE INDEX draft_pkey_legacy
ON draft (workspace_id, path, typ)
WHERE email IS NULL;
-- Hot path for `sync_drafts`: each active editor polls
-- `WHERE workspace_id = ? AND email = ? [AND created_at > ?]` every 2-10s.
-- Neither partial unique index above helps (both lead with `path, typ`);
-- this covers the initial sync (leading two columns) and the incremental
-- missed-drafts query (range scan on `created_at`).
CREATE INDEX draft_user_sync_idx
ON draft (workspace_id, email, created_at)
WHERE email IS NOT NULL;
ALTER TABLE draft ADD COLUMN id BIGSERIAL PRIMARY KEY;
+141 -100
View File
@@ -21,7 +21,8 @@ use windmill_api_auth::{
};
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
use windmill_common::{
utils::{WithStarredInfoQuery, HTTP_CLIENT},
user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
utils::HTTP_CLIENT,
webhook::{WebhookMessage, WebhookShared},
DB,
};
@@ -49,7 +50,6 @@ use windmill_common::{
flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow},
jobs::JobPayload,
schedule::Schedule,
scripts::Schema,
utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath},
};
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
@@ -68,7 +68,6 @@ pub fn workspaced_service() -> Router {
.route("/list_tokens/{*path}", get(list_tokens))
.route("/get/{*path}", get(get_flow_by_path))
.route("/deployment_status/p/{*path}", get(get_deployment_status))
.route("/get/draft/{*path}", get(get_flow_by_path_w_draft))
.route("/exists/{*path}", get(exists_flow_by_path))
.route("/list_paths", get(list_paths))
.route("/history/p/{*path}", get(get_flow_history))
@@ -130,6 +129,7 @@ async fn list_search_flows(
async fn list_flows(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListFlowQuery>,
@@ -151,10 +151,10 @@ async fn list_flows(
"archived",
"extra_perms",
"favorite.path IS NOT NULL as starred",
"draft.path IS NOT NULL as has_draft",
"draft_only",
"ws_error_handler_muted",
"o.labels"
"o.labels",
"draft.email IS NOT NULL as is_draft",
])
.left()
.join("favorite")
@@ -165,7 +165,8 @@ async fn list_flows(
.left()
.join("draft")
.on(
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'"
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?"
.bind(&authed.email),
)
.left()
.join("flow_version fv")
@@ -216,13 +217,97 @@ async fn list_flows(
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let allowed = build_scope_path_predicate(&authed, "flows", "read");
let rows = sqlx::query_as::<_, ListableFlow>(&sql)
let mut rows = sqlx::query_as::<_, ListableFlow>(&sql)
.fetch_all(&mut *tx)
.await?
.into_iter()
.filter(|r| allowed(&r.path))
.collect::<Vec<_>>();
tx.commit().await?;
// Draft-only rows: drafts the authed user has at paths with no
// deployed flow. Gated on the same `include_draft_only` flag that
// controls deployed `draft_only` rows above so picker callers
// (workspace pickers, script selectors, ...) get the deployed
// listing only — the home page opts in explicitly.
//
// Concatenated after the deployed page so the home page surfaces
// them too. Fields not in the draft JSON fall back to sensible
// defaults. `path_start` is honored in-query (so prefix listings
// still include draft-only rows); other narrowing filters or pages
// past 0 skip the append to keep pagination semantics clean.
if lq.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& offset == 0
&& lq.path_exact.is_none()
&& lq.edited_by.is_none()
&& lq.dedicated_worker.is_none()
&& lq.label.is_none()
&& !lq.starred_only.unwrap_or(false)
&& !lq.show_archived.unwrap_or(false)
{
let draft_only_rows = sqlx::query!(
r#"SELECT path,
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at
FROM draft
WHERE workspace_id = $1
AND typ = 'flow'
AND email = $2
AND ($3::text IS NULL OR path LIKE $3 || '%')
AND NOT EXISTS (
SELECT 1 FROM flow f
WHERE f.workspace_id = draft.workspace_id
AND f.path = draft.path
)"#,
&w_id,
&authed.email,
lq.path_start.as_deref(),
)
.fetch_all(&db)
.await?;
for row in draft_only_rows {
let v: serde_json::Value =
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
// The Flow editor's autosave never updates `flow.path` from
// the Path widget — the widget binds `$pathStore` directly,
// which is one-way `flow.path → $pathStore`. So the editor
// writes a separate `draft_path` field into the draft JSON
// when (and only when) the typed path differs from the
// deployed one. `None` here = unchanged.
let draft_path = v
.get("draft_path")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != row.path.as_str())
.map(|s| s.to_string());
rows.push(ListableFlow {
workspace_id: w_id.clone(),
path: row.path,
summary: v
.get("summary")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string(),
description: v
.get("description")
.and_then(|s| s.as_str())
.map(|s| s.to_string()),
edited_by: Some(authed.email.clone()),
edited_at: Some(row.created_at),
archived: false,
extra_perms: serde_json::Value::Object(serde_json::Map::new()),
starred: false,
draft_only: Some(true),
ws_error_handler_muted: None,
deployment_msg: None,
labels: None,
is_draft: true,
draft_path,
});
}
}
Ok(Json(rows))
}
@@ -714,18 +799,6 @@ async fn check_schedule_conflict<'c>(
Ok(())
}
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
return windmill_api_auth::require_is_writer(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2",
"flow",
)
.await;
}
#[derive(Serialize)]
pub struct FlowVersion {
pub id: i64,
@@ -1392,12 +1465,24 @@ async fn get_deployment_status(
Ok(Json(deployment_status))
}
// Fields inlined rather than flattened from WithStarredInfoQuery /
// WithDraftQuery — see the same comment on `GetScriptByPathQuery` in
// scripts.rs: axum's `serde_urlencoded` query extractor doesn't preserve
// the "true"/"false" → bool conversion through `#[serde(flatten)]`.
#[derive(Deserialize)]
struct GetFlowByPathQuery {
with_starred_info: Option<bool>,
#[serde(default)]
get_draft: bool,
}
async fn get_flow_by_path(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<WithStarredInfoQuery>,
) -> JsonResult<FlowWithStarred> {
Query(query): Query<GetFlowByPathQuery>,
) -> JsonResult<WithDraftOverlay> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
@@ -1438,7 +1523,7 @@ async fn get_flow_by_path(
"#,
)
.bind(path)
.bind(w_id)
.bind(&w_id)
.bind(&authed.username)
.fetch_optional(&mut *tx)
.await?
@@ -1474,90 +1559,46 @@ async fn get_flow_by_path(
"#,
)
.bind(path)
.bind(w_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
};
tx.commit().await?;
let flow = not_found_if_none(flow_o, "Flow", path)?;
Ok(Json(flow))
}
#[derive(Serialize, sqlx::FromRow)]
pub struct FlowWDraft {
pub path: String,
pub summary: String,
pub description: String,
pub schema: Option<Schema>,
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
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")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedicated_worker: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible_to_runner_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
async fn get_flow_by_path_w_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<FlowWDraft> {
let path = path.to_path();
check_scopes(&authed, || format!("flows:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let flow_o = sqlx::query_as::<_, FlowWDraft>(
"SELECT
flow.path,
flow.summary,
flow.description,
flow_version.schema,
flow_version.value,
flow.extra_perms,
flow.draft_only,
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,
flow.labels
FROM flow
LEFT JOIN draft
ON flow.path = draft.path
AND draft.workspace_id = $2
AND draft.typ = 'flow'
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1
AND flow.workspace_id = $2",
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let flow = not_found_if_none(flow_o, "Flow", path)?;
Ok(Json(flow))
// Editors that have only ever drafted (never deployed) a flow at this
// path will land here with no deployed row. When `get_draft` is set,
// fall back to the draft table so /flows/edit/draft_<uuid> works the
// same way as a deployed-flow reload.
let overlay = match flow_o {
Some(flow) => {
maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Flow,
path,
query.get_draft,
flow,
)
.await?
}
None if query.get_draft => {
fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Flow, path)
.await?
.ok_or_else(|| {
windmill_common::error::Error::NotFound(format!(
"Flow not found at path {path}"
))
})?
}
None => {
return Err(windmill_common::error::Error::NotFound(format!(
"Flow not found at path {path}"
)));
}
};
Ok(Json(overlay))
}
async fn exists_flow_by_path(
@@ -82,12 +82,6 @@ async fn test_app_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let resp = authed_get(port, "get/p", "u/test-user/nonexistent").await;
assert_eq!(resp.status(), 404);
// --- get draft ---
let resp = authed_get(port, "get/draft", "u/test-user/test_app").await;
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["path"], "u/test-user/test_app");
// --- get lite ---
let resp = authed_get(port, "get/lite", "u/test-user/test_app").await;
assert_eq!(resp.status(), 200);
@@ -1,105 +0,0 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_draft_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/drafts");
// create a script first so the draft has a valid path
let resp = authed(client().post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
)))
.json(&json!({
"path": "u/test-user/draft_script",
"summary": "Script for draft test",
"description": "",
"content": "export async function main() { return 1; }",
"language": "deno",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {},
"required": []
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?);
// --- create draft ---
let resp = authed(client().post(format!("{base}/create")))
.json(&json!({
"path": "u/test-user/draft_script",
"typ": "script",
"value": {
"content": "export async function main() { return 2; }",
"language": "deno"
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "create draft: {}", resp.text().await?);
// verify draft exists via script get/draft endpoint
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert!(body["draft"].is_object(), "expected draft to be present");
// --- update draft (create with same path overwrites) ---
let resp = authed(client().post(format!("{base}/create")))
.json(&json!({
"path": "u/test-user/draft_script",
"typ": "script",
"value": {
"content": "export async function main() { return 3; }",
"language": "deno"
}
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
// --- delete draft ---
let resp = authed(client().delete(format!(
"{base}/delete/script/u/test-user/draft_script"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// verify draft is gone
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script"
)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert!(body["draft"].is_null(), "expected draft to be deleted");
Ok(())
}
@@ -82,12 +82,6 @@ async fn test_flow_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let resp = authed_get(port, "get", "u/test-user/nonexistent").await;
assert_eq!(resp.status(), 404);
// --- get draft ---
let resp = authed_get(port, "get/draft", "u/test-user/test_flow").await;
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["path"], "u/test-user/test_flow");
// --- list ---
let resp = authed(client().get(format!("{base}/list")))
.send()
@@ -98,12 +98,6 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["path"], "u/test-user/test_script");
// --- get draft ---
let resp = authed_get(port, "get/draft", "u/test-user/test_script").await;
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["path"], "u/test-user/test_script");
// --- raw by path (requires language extension) ---
let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await;
assert_eq!(resp.status(), 200);
+28 -2
View File
@@ -25,6 +25,9 @@ use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
schedule::Schedule,
user_drafts::{
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{
escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath,
},
@@ -808,8 +811,10 @@ async fn list_schedule_with_jobs(
async fn get_schedule(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Schedule> {
Query(q): Query<WithDraftQuery>,
) -> JsonResult<WithDraftOverlay> {
let path = path.to_path();
check_scopes(&authed, || format!("schedules:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
@@ -817,7 +822,17 @@ async fn get_schedule(
let schedule_o = windmill_queue::schedule::get_schedule_opt(&mut *tx, &w_id, path).await?;
let schedule = not_found_if_none(schedule_o, "Schedule", path)?;
tx.commit().await?;
Ok(Json(schedule))
let overlay = maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::TriggerSchedule,
path,
q.get_draft,
schedule,
)
.await?;
Ok(Json(overlay))
}
async fn exists_schedule(
@@ -1106,6 +1121,17 @@ async fn delete_schedule(
tx.commit().await?;
// Clean up the authed user's per-user draft for this schedule path.
// Idempotent on no-draft.
delete_user_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::TriggerSchedule,
path,
)
.await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
+166 -176
View File
@@ -13,6 +13,7 @@ use windmill_api_auth::{
ApiAuthed,
};
use windmill_common::{
user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
webhook::{WebhookMessage, WebhookShared},
workspaces::{check_deploy_rules, RuleCheckResult},
@@ -33,7 +34,6 @@ use itertools::Itertools;
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::value::RawValue;
use sql_builder::prelude::*;
use sqlx::{FromRow, Postgres, Transaction};
use std::{collections::HashMap, sync::Arc};
@@ -45,7 +45,7 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_common::{
assets::{
clear_static_asset_usage, clear_static_asset_usage_by_script_hash,
insert_static_asset_usage, AssetUsageKind, AssetWithAltAccessType,
insert_static_asset_usage, AssetUsageKind,
},
error::{self, to_anyhow},
min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2},
@@ -82,122 +82,6 @@ use windmill_queue::{
const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20;
#[derive(Serialize, sqlx::FromRow)]
pub struct ScriptWDraft<SR> {
pub hash: ScriptHash,
pub path: String,
pub summary: String,
pub description: String,
pub content: String,
pub language: ScriptLang,
pub kind: ScriptKind,
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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub envs: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_ignore_s3_path: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dedicated_worker: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub restart_unless_cancelled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_after_use: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_after_secs: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visible_to_runner_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_preprocessor: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(json(nullable))]
pub assets: Option<Vec<AssetWithAltAccessType>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(json(nullable))]
pub modules: Option<HashMap<String, ScriptModule>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
#[serde(flatten)]
#[sqlx(flatten)]
pub runnable_settings: SR,
}
impl ScriptWDraft<ScriptRunnableSettingsHandle> {
pub async fn prefetch_cached<'a>(
self,
db: &DB,
) -> error::Result<ScriptWDraft<ScriptRunnableSettingsInline>> {
let (debouncing_settings, concurrency_settings) =
windmill_common::runnable_settings::prefetch_cached_from_handle(
self.runnable_settings.runnable_settings_handle,
db,
)
.await?;
Ok(ScriptWDraft {
runnable_settings: ScriptRunnableSettingsInline {
concurrency_settings: concurrency_settings.maybe_fallback(
self.runnable_settings.concurrency_key,
self.runnable_settings.concurrent_limit,
self.runnable_settings.concurrency_time_window_s,
),
debouncing_settings: debouncing_settings.maybe_fallback(
self.runnable_settings.debounce_key,
self.runnable_settings.debounce_delay_s,
),
},
hash: self.hash,
path: self.path,
summary: self.summary,
description: self.description,
content: self.content,
language: self.language,
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,
cache_ttl: self.cache_ttl,
cache_ignore_s3_path: self.cache_ignore_s3_path,
dedicated_worker: self.dedicated_worker,
ws_error_handler_muted: self.ws_error_handler_muted,
priority: self.priority,
restart_unless_cancelled: self.restart_unless_cancelled,
delete_after_use: self.delete_after_use,
delete_after_secs: self.delete_after_secs,
timeout: self.timeout,
visible_to_runner_only: self.visible_to_runner_only,
auto_kind: self.auto_kind,
has_preprocessor: self.has_preprocessor,
on_behalf_of_email: self.on_behalf_of_email,
assets: self.assets,
modules: self.modules,
labels: self.labels,
})
}
}
pub fn global_service() -> Router {
Router::new()
.route("/hub/top", get(get_top_hub_scripts))
@@ -222,7 +106,6 @@ pub fn workspaced_service() -> Router {
.route("/create", post(create_script))
.route("/create_snapshot", post(create_snapshot_script))
.route("/archive/p/{*path}", post(archive_script_by_path))
.route("/get/draft/{*path}", get(get_script_by_path_w_draft))
.route("/get/p/{*path}", get(get_script_by_path))
.route("/list_tokens/{*path}", get(list_tokens))
.route("/raw/p/{*path}", get(raw_script_by_path))
@@ -295,6 +178,7 @@ async fn list_search_scripts(
async fn list_scripts(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListScriptQuery>,
@@ -305,7 +189,7 @@ async fn list_scripts(
"hash",
"o.path",
"summary",
"COALESCE(draft.created_at, o.created_at) as created_at",
"o.created_at as created_at",
"archived",
"extra_perms",
if !lq.without_description.unwrap_or(false) {
@@ -317,13 +201,13 @@ async fn list_scripts(
"language",
"favorite.path IS NOT NULL as starred",
"tag",
"draft.path IS NOT NULL as has_draft",
"draft_only",
"ws_error_handler_muted",
"auto_kind",
"codebase IS NOT NULL as use_codebase",
"kind",
"o.labels"
"o.labels",
"draft.email IS NOT NULL as is_draft",
])
.left()
.join("favorite")
@@ -334,7 +218,8 @@ async fn list_scripts(
.left()
.join("draft")
.on(
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'"
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script' AND draft.email = ?"
.bind(&authed.email),
)
.order_desc("favorite.path IS NOT NULL")
.order_by("created_at", lq.order_desc.unwrap_or(true))
@@ -429,7 +314,7 @@ async fn list_scripts(
.fields(&["dm.deployment_msg"]);
}
if let Some(languages) = lq.languages {
if let Some(languages) = &lq.languages {
sqlb.and_where_in(
"language",
&languages
@@ -442,13 +327,112 @@ async fn list_scripts(
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let allowed = build_scope_path_predicate(&authed, "scripts", "read");
let rows = sqlx::query_as::<_, ListableScript>(&sql)
let mut rows = sqlx::query_as::<_, ListableScript>(&sql)
.fetch_all(&mut *tx)
.await?
.into_iter()
.filter(|r| allowed(&r.path))
.collect::<Vec<_>>();
tx.commit().await?;
// Draft-only rows: drafts the authed user has at paths with no
// deployed script. Gated on the same `include_draft_only` flag
// that controls deployed `draft_only` rows above so picker callers
// (workspace pickers, script selectors, ...) get the deployed
// listing only — the home page opts in explicitly.
//
// Concatenated after the deployed page so the home page surfaces
// them too. Fields not in the draft JSON fall back to sensible
// defaults. `path_start` is honored in-query (so prefix listings
// still include draft-only rows); other narrowing filters or pages
// past 0 skip the append to keep pagination semantics clean.
if lq.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& offset == 0
&& lq.path_exact.is_none()
&& lq.created_by.is_none()
&& lq.first_parent_hash.is_none()
&& lq.last_parent_hash.is_none()
&& lq.parent_hash.is_none()
&& lq.is_template.is_none()
&& lq.dedicated_worker.is_none()
&& lq.label.is_none()
&& lq.languages.is_none()
&& !lq.starred_only.unwrap_or(false)
&& !lq.show_archived.unwrap_or(false)
{
let draft_only_rows = sqlx::query!(
r#"SELECT path,
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at
FROM draft
WHERE workspace_id = $1
AND typ = 'script'
AND email = $2
AND ($3::text IS NULL OR path LIKE $3 || '%')
AND NOT EXISTS (
SELECT 1 FROM script s
WHERE s.workspace_id = draft.workspace_id
AND s.path = draft.path
)"#,
&w_id,
&authed.email,
lq.path_start.as_deref(),
)
.fetch_all(&db)
.await?;
for row in draft_only_rows {
let v: serde_json::Value =
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
let language: ScriptLang = v
.get("language")
.and_then(|x| serde_json::from_value(x.clone()).ok())
.unwrap_or_default();
let kind: ScriptKind = v
.get("kind")
.and_then(|x| serde_json::from_value(x.clone()).ok())
.unwrap_or(ScriptKind::Script);
// Scripts bind the Path widget directly to `script.path`, so
// the user-typed path round-trips through the draft JSON's
// own `path` field — no separate `draft_path` field needed.
let draft_path = v
.get("path")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != row.path.as_str())
.map(|s| s.to_string());
rows.push(ListableScript {
hash: ScriptHash(0),
path: row.path,
summary: v
.get("summary")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string(),
created_at: row.created_at,
archived: false,
extra_perms: serde_json::Value::Object(serde_json::Map::new()),
language,
starred: false,
tag: v.get("tag").and_then(|s| s.as_str()).map(|s| s.to_string()),
description: v
.get("description")
.and_then(|s| s.as_str())
.map(|s| s.to_string()),
draft_only: Some(true),
has_deploy_errors: false,
ws_error_handler_muted: None,
auto_kind: None,
use_codebase: false,
deployment_msg: None,
kind,
labels: None,
is_draft: true,
draft_path,
});
}
}
Ok(Json(rows))
}
@@ -1768,14 +1752,28 @@ pub async fn pick_hub_script_by_path(
Ok::<_, Error>((status_code, headers, response))
}
// NOTE: inlined fields rather than `#[serde(flatten)]` on
// `WithStarredInfoQuery` / `WithDraftQuery`. axum's default query
// extractor uses `serde_urlencoded`, and flatten there routes through an
// internal map that drops type info — so `?get_draft=true` arrives as a
// `String` and fails the inner `bool` deserializer. Inlining keeps the
// fields on the top-level struct so `serde_urlencoded`'s own bool
// adapter sees the value directly.
#[derive(Deserialize)]
struct GetScriptByPathQuery {
with_starred_info: Option<bool>,
#[serde(default)]
get_draft: bool,
}
#[axum::debug_handler]
async fn get_script_by_path(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<WithStarredInfoQuery>,
) -> JsonResult<ScriptWithStarred<ScriptRunnableSettingsInline>> {
Query(query): Query<GetScriptByPathQuery>,
) -> JsonResult<WithDraftOverlay> {
let path = path.to_path();
check_scopes(&authed, || format!("scripts:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
@@ -1785,16 +1783,16 @@ async fn get_script_by_path(
"SELECT s.*, favorite.path IS NOT NULL as starred
FROM script s
LEFT JOIN favorite
ON favorite.favorite_kind = 'script'
AND favorite.workspace_id = s.workspace_id
AND favorite.path = s.path
ON favorite.favorite_kind = 'script'
AND favorite.workspace_id = s.workspace_id
AND favorite.path = s.path
AND favorite.usr = $3
WHERE s.path = $1
AND s.workspace_id = $2
ORDER BY s.created_at DESC LIMIT 1",
)
.bind(path)
.bind(w_id)
.bind(&w_id)
.bind(&authed.username)
.fetch_optional(&mut *tx)
.await?
@@ -1806,19 +1804,49 @@ async fn get_script_by_path(
),
)
.bind(path)
.bind(w_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
};
tx.commit().await?;
let script = windmill_common::scripts::prefetch_cached_script_with_starred(
not_found_if_none(script_o, "Script", path)?,
&db,
)
.await?;
// Editors that have only ever drafted (never deployed) a script at this
// path will land here with no deployed row. When `get_draft` is set, fall
// back to the draft table so /scripts/edit/draft_<uuid> works the same
// way as a deployed-script reload.
let overlay = match script_o {
Some(script_o) => {
let script =
windmill_common::scripts::prefetch_cached_script_with_starred(script_o, &db)
.await?;
maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Script,
path,
query.get_draft,
script,
)
.await?
}
None if query.get_draft => {
fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Script, path)
.await?
.ok_or_else(|| {
windmill_common::error::Error::NotFound(format!(
"Script not found at path {path}"
))
})?
}
None => {
return Err(windmill_common::error::Error::NotFound(format!(
"Script not found at path {path}"
)))
}
};
Ok(Json(script))
Ok(Json(overlay))
}
async fn list_tokens(
@@ -1829,32 +1857,6 @@ async fn list_tokens(
list_tokens_internal(&db, &w_id, &path, false).await
}
async fn get_script_by_path_w_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<ScriptWDraft<ScriptRunnableSettingsInline>> {
let path = path.to_path();
check_scopes(&authed, || format!("scripts:read:{}", path))?;
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, 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",
)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let script = not_found_if_none(script_o, "Script", path)?;
Ok(Json(script.prefetch_cached(&db).await?))
}
async fn get_script_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -2514,18 +2516,6 @@ async fn get_deployment_status(
Ok(Json(deployment_status))
}
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
return windmill_api_auth::require_is_writer(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1",
"script",
)
.await;
}
async fn archive_script_by_path(
authed: ApiAuthed,
Extension(webhook): Extension<WebhookShared>,
+287 -154
View File
@@ -5324,13 +5324,16 @@ paths:
in: query
schema:
type: boolean
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: variable
content:
application/json:
schema:
$ref: "#/components/schemas/ListableVariable"
allOf:
- $ref: "#/components/schemas/ListableVariable"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/variables/get_value/{path}:
get:
@@ -6647,13 +6650,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: resource
content:
application/json:
schema:
$ref: "#/components/schemas/Resource"
allOf:
- $ref: "#/components/schemas/ListableResource"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/resources/get_value_interpolated/{path}:
get:
@@ -7782,7 +7788,26 @@ paths:
schema:
type: array
items:
$ref: "#/components/schemas/Script"
allOf:
- $ref: "#/components/schemas/Script"
- type: object
properties:
is_draft:
type: boolean
description: |
True when the authed user has a draft for this
script — either no deployed row exists at this
path (draft-only) or the user saved a per-user
draft on top of the deployed row.
draft_path:
type: string
description: |
User-typed path the editor has staged but not
yet deployed. Surfaced for draft-only rows so
the home list can render the meaningful name
instead of the autogenerated
`u/{user}/draft_{uuid}` URL path. Omitted
when unchanged.
/w/{workspace}/scripts/list_paths:
get:
@@ -7802,43 +7827,10 @@ paths:
items:
type: string
/w/{workspace}/drafts/create:
post:
summary: create draft
operationId: createDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
path:
type: string
typ:
type: string
enum: ["flow", "script", "app"]
value: {}
required:
- path
- typ
- enum
responses:
"201":
description: draft created
content:
text/plain:
schema:
type: string
/w/{workspace}/drafts/delete/{kind}/{path}:
delete:
summary: delete draft
operationId: deleteDraft
/w/{workspace}/drafts/get/{kind}/{path}:
get:
summary: fetch a single draft's content by workspace username (or the legacy workspace-level row)
operationId: getDraftForUser
tags:
- draft
parameters:
@@ -7846,20 +7838,107 @@ paths:
- name: kind
in: path
required: true
schema:
$ref: "#/components/schemas/UserDraftItemKind"
- $ref: "#/components/parameters/ScriptPath"
- name: username
in: query
required: false
description: Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row.
schema:
type: string
enum:
- script
- flow
- app
responses:
"200":
description: draft content
content:
application/json:
schema:
type: object
properties:
value: {}
created_at:
type: string
format: date-time
required: [value, created_at]
"404":
description: no draft for that owner at that path
/w/{workspace}/drafts/save_draft/{kind}/{path}:
post:
summary: save the current user's draft at a path
operationId: saveDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
schema:
$ref: "#/components/schemas/UserDraftItemKind"
- $ref: "#/components/parameters/ScriptPath"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
value:
nullable: true
description: Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules.
last_sync:
type: string
format: date-time
description: Server timestamp of the client's last known sync for this draft. Omit on first save.
force:
type: boolean
description: Skip the conflict check and overwrite the server copy.
responses:
"200":
description: save result
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [saved, conflict]
current_timestamp:
type: string
format: date-time
required: [status, current_timestamp]
/w/{workspace}/drafts/get_draft/{kind}/{path}:
get:
summary: fetch the current user's draft at a path
operationId: getDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
schema:
$ref: "#/components/schemas/UserDraftItemKind"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: draft deleted
description: draft content
content:
text/plain:
application/json:
schema:
type: string
type: object
properties:
value: {}
saved_at:
type: string
format: date-time
required: [value, saved_at]
"404":
description: no draft for the current user at that path
/w/{workspace}/scripts/create:
post:
@@ -8238,13 +8317,16 @@ paths:
in: query
schema:
type: boolean
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: script details
content:
application/json:
schema:
$ref: "#/components/schemas/Script"
allOf:
- $ref: "#/components/schemas/Script"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/scripts/get_triggers_count/{path}:
get:
@@ -8282,23 +8364,6 @@ paths:
items:
$ref: "#/components/schemas/TruncatedToken"
/w/{workspace}/scripts/get/draft/{path}:
get:
summary: get script by path with draft
operationId: getScriptByPathWithDraft
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: script details
content:
application/json:
schema:
$ref: "#/components/schemas/NewScriptWithDraft"
/w/{workspace}/scripts/history/p/{path}:
get:
summary: get history of a script by path
@@ -9474,10 +9539,26 @@ paths:
- $ref: "#/components/schemas/Flow"
- type: object
properties:
has_draft:
type: boolean
draft_only:
type: boolean
is_draft:
type: boolean
description: |
True when the authed user has a draft for this
flow — either no deployed row exists at this
path (draft-only) or the user saved a per-user
draft on top of the deployed row.
draft_path:
type: string
description: |
User-typed path the editor has staged but not
yet deployed. Sourced from the draft JSON's
`draft_path` field (the editor only writes it
when the typed path differs from the deployed
one). Lets the home list render the meaningful
name instead of the autogenerated
`u/{user}/draft_{uuid}` URL path. Omitted when
unchanged.
/w/{workspace}/flows/history/p/{path}:
get:
@@ -9607,13 +9688,16 @@ paths:
in: query
schema:
type: boolean
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: flow details
content:
application/json:
schema:
$ref: "#/components/schemas/Flow"
allOf:
- $ref: "#/components/schemas/Flow"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/flows/deployment_status/p/{path}:
get:
@@ -9701,32 +9785,6 @@ paths:
schema:
type: string
/w/{workspace}/flows/get/draft/{path}:
get:
summary: get flow by path with draft
operationId: getFlowByPathWithDraft
tags:
- flow
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: flow details with draft
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Flow"
- type: object
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:
summary: exists flow by path
@@ -10428,13 +10486,24 @@ paths:
in: query
schema:
type: boolean
- $ref: "#/components/parameters/GetDraft"
- name: raw_app
in: query
description: |
When no deployed app exists at this path and `get_draft` is set,
disambiguates which draft kind (`raw_app` or `app`) to look up.
Ignored when a deployed row exists.
schema:
type: boolean
responses:
"200":
description: app details
content:
application/json:
schema:
$ref: "#/components/schemas/AppWithLastVersion"
allOf:
- $ref: "#/components/schemas/AppWithLastVersion"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/apps/get/lite/{path}:
get:
@@ -10453,23 +10522,6 @@ paths:
schema:
$ref: "#/components/schemas/AppWithLastVersion"
/w/{workspace}/apps/get/draft/{path}:
get:
summary: get app by path with draft
operationId: getAppByPathWithDraft
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: app details with draft
content:
application/json:
schema:
$ref: "#/components/schemas/AppWithLastVersionWDraft"
/w/{workspace}/apps/history/p/{path}:
get:
summary: get app history by path
@@ -13626,13 +13678,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: schedule deleted
content:
application/json:
schema:
$ref: "#/components/schemas/Schedule"
allOf:
- $ref: "#/components/schemas/Schedule"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/schedules/exists/{path}:
get:
@@ -13915,13 +13970,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: http trigger deleted
content:
application/json:
schema:
$ref: "#/components/schemas/HttpTrigger"
allOf:
- $ref: "#/components/schemas/HttpTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/http_triggers/list:
get:
@@ -14121,13 +14179,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: websocket trigger deleted
content:
application/json:
schema:
$ref: "#/components/schemas/WebsocketTrigger"
allOf:
- $ref: "#/components/schemas/WebsocketTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/websocket_triggers/list:
get:
@@ -14326,13 +14387,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: kafka trigger deleted
content:
application/json:
schema:
$ref: "#/components/schemas/KafkaTrigger"
allOf:
- $ref: "#/components/schemas/KafkaTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/kafka_triggers/list:
get:
@@ -14572,13 +14636,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: nats trigger deleted
content:
application/json:
schema:
$ref: "#/components/schemas/NatsTrigger"
allOf:
- $ref: "#/components/schemas/NatsTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/nats_triggers/list:
get:
@@ -14772,13 +14839,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: sqs trigger deleted
content:
application/json:
schema:
$ref: "#/components/schemas/SqsTrigger"
allOf:
- $ref: "#/components/schemas/SqsTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/sqs_triggers/list:
get:
@@ -15565,13 +15635,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: mqtt trigger deleted
content:
application/json:
schema:
$ref: "#/components/schemas/MqttTrigger"
allOf:
- $ref: "#/components/schemas/MqttTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/mqtt_triggers/list:
get:
@@ -15765,13 +15838,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: gcp trigger deleted
content:
application/json:
schema:
$ref: "#/components/schemas/GcpTrigger"
allOf:
- $ref: "#/components/schemas/GcpTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/gcp_triggers/list:
get:
@@ -16032,13 +16108,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: azure trigger
content:
application/json:
schema:
$ref: "#/components/schemas/AzureTrigger"
allOf:
- $ref: "#/components/schemas/AzureTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/azure_triggers/list:
get:
@@ -16575,13 +16654,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: get postgres trigger
content:
application/json:
schema:
$ref: "#/components/schemas/PostgresTrigger"
allOf:
- $ref: "#/components/schemas/PostgresTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/postgres_triggers/list:
get:
@@ -16775,13 +16857,16 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
- $ref: "#/components/parameters/GetDraft"
responses:
"200":
description: email trigger retrieved
content:
application/json:
schema:
$ref: "#/components/schemas/EmailTrigger"
allOf:
- $ref: "#/components/schemas/EmailTrigger"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/email_triggers/list:
get:
@@ -20530,6 +20615,13 @@ components:
name: token
parameters:
GetDraft:
name: get_draft
in: query
required: false
description: When true, overlay the authed user's draft (if any) onto the deployed payload.
schema:
type: boolean
Id:
name: id
in: path
@@ -20961,6 +21053,63 @@ components:
# This is why it is better to inline each of schemas for better compat
# Do not change next line. It is used by python-client for pre-processing
# -- INLINE START --
UserDraftOverlay:
type: object
description: |
Overlay fields added to every "get by path" response that accepts
the `get_draft` query parameter. The deployed payload is sent
untouched in the response body; the authed user's saved draft
for this path — whatever shape the editor wrote — is attached
as the sibling `draft` field when `get_draft=true` and a draft
exists. The frontend pairs the two to present diff / reset /
discard UI; the server never merges them.
When `no_deployed=true` there is no deployed row at this path —
the response body is a best-effort stand-in synthesized from
the draft, and only `draft` is canonical. Callers should disable
"diff vs deployed" UI in that case.
properties:
is_draft:
type: boolean
draft_saved_at:
type: string
format: date-time
no_deployed:
type: boolean
draft:
type: object
additionalProperties: true
required: [is_draft]
UserDraftItemKind:
type: string
description: |
Closed set of item kinds a user can autosave as a draft. Mirrors the
Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.
enum:
- 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
OpenFlow:
$ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow"
FlowValue:
@@ -21781,8 +21930,6 @@ components:
type: boolean
tag:
type: string
has_draft:
type: boolean
draft_only:
type: boolean
envs:
@@ -21982,22 +22129,6 @@ components:
- content
- language
NewScriptWithDraft:
allOf:
- $ref: "#/components/schemas/NewScript"
- type: object
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:
- hash
ScriptHistory:
type: object
properties:
@@ -26939,6 +27070,21 @@ components:
items:
type: string
default: []
is_draft:
type: boolean
description: |
True when the authed user has a draft for this app — either no
deployed row exists at this path (draft-only) or the user has
saved a per-user draft on top of the deployed row.
draft_path:
type: string
description: |
User-typed path the editor has staged but not yet deployed.
Sourced from the draft JSON's `draft_path` field (the editor
only writes it when the typed path differs from the deployed
one). Lets the home list render the meaningful name instead of
the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted
when unchanged.
required:
- id
- workspace_id
@@ -27073,19 +27219,6 @@ components:
- raw_app
AppWithLastVersionWDraft:
allOf:
- $ref: "#/components/schemas/AppWithLastVersion"
- type: object
properties:
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
properties:
+166 -93
View File
@@ -12,7 +12,7 @@ use crate::{
db::{ApiAuthed, DB},
jobs::RunJobQuery,
users::{require_owner_of_path, require_path_read_access_for_preview, OptAuthed},
utils::{check_scopes, WithStarredInfoQuery},
utils::check_scopes,
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
};
@@ -58,6 +58,7 @@ use windmill_common::{
get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion,
JobPayload, RawCode,
},
user_drafts::{fetch_draft_only, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
users::username_to_permissioned_as,
utils::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
@@ -88,7 +89,6 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
.route("/list_search", get(list_search_apps))
.route("/get/p/{*path}", get(get_app))
.route("/get/lite/{*path}", get(get_app_lite))
.route("/get/draft/{*path}", get(get_app_w_draft))
.route("/secret_of/{*path}", get(get_secret_id))
.route(
"/secret_of_latest_version/{*path}",
@@ -153,7 +153,6 @@ pub struct ListableApp {
pub execution_mode: String,
pub starred: bool,
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
pub has_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[sqlx(default)]
@@ -163,6 +162,20 @@ pub struct ListableApp {
pub raw_app: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
/// True when the authed user has a draft for this app — either the
/// row is draft-only (no deployed app at this path) or the user has
/// saved a per-user draft on top of the deployed row.
#[serde(default, skip_serializing_if = "is_false")]
pub is_draft: bool,
/// User-typed path the editor has staged but not yet deployed —
/// sourced from the draft JSON's `draft_path` field, which the app
/// editor only writes when the typed path differs from the deployed
/// one. Lets the home list render the meaningful name instead of
/// the autogenerated `u/{user}/draft_{uuid}` URL path. `None` when
/// unchanged.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
}
fn is_false(b: &bool) -> bool {
@@ -208,20 +221,6 @@ pub struct AppWithLastVersionAndStarred {
pub starred: Option<bool>,
}
#[derive(Serialize, Deserialize, FromRow)]
pub struct AppWithLastVersionAndDraft {
#[sqlx(flatten)]
#[serde(flatten)]
pub app: AppWithLastVersion,
#[serde(skip_serializing_if = "Option::is_none")]
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)]
pub struct AppHistory {
pub app_id: i64,
@@ -364,6 +363,7 @@ async fn list_search_apps(
async fn list_apps(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListAppQuery>,
@@ -381,10 +381,10 @@ async fn list_apps(
"app_version.created_at as edited_at",
"app.extra_perms",
"favorite.path IS NOT NULL as starred",
"draft.path IS NOT NULL as has_draft",
"draft_only",
"app_version.raw_app",
"app.labels",
"draft.email IS NOT NULL as is_draft",
])
.left()
.join("favorite")
@@ -393,15 +393,19 @@ async fn list_apps(
.bind(&authed.username),
)
.left()
.join("draft")
.on(
// `app` and `raw_app` are stored as separate draft kinds but
// share the `app` table — match either kind for the per-user
// flag.
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ IN ('app', 'raw_app') AND draft.email = ?"
.bind(&authed.email),
)
.left()
.join("app_version")
.on(
"app_version.id = versions[array_upper(versions, 1)]"
)
.left()
.join("draft")
.on(
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'"
)
.order_desc("favorite.path IS NOT NULL")
.order_by("app_version.created_at", true)
.and_where("app.workspace_id = ?".bind(&w_id))
@@ -440,12 +444,89 @@ async fn list_apps(
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableApp>(&sql)
let mut rows = sqlx::query_as::<_, ListableApp>(&sql)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
// Draft-only rows: drafts (of either `app` or `raw_app` kind) the
// authed user has at paths with no deployed app. Gated on the same
// `include_draft_only` flag that controls deployed `draft_only`
// rows above so picker callers get the deployed listing only — the
// home page opts in explicitly.
//
// Concatenated after the deployed page so the home page surfaces
// them too. Fields not in the draft JSON fall back to defaults.
// `path_start` is honored in-query (so prefix listings still include
// draft-only rows); other narrowing filters or pages past 0 skip the
// append to keep pagination clean.
if lq.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& offset == 0
&& lq.path_exact.is_none()
&& lq.label.is_none()
&& !lq.starred_only.unwrap_or(false)
{
let draft_only_rows = sqlx::query!(
r#"SELECT path,
value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at,
typ::text as "typ!"
FROM draft
WHERE workspace_id = $1
AND typ IN ('app', 'raw_app')
AND email = $2
AND ($3::text IS NULL OR path LIKE $3 || '%')
AND NOT EXISTS (
SELECT 1 FROM app a
WHERE a.workspace_id = draft.workspace_id
AND a.path = draft.path
)"#,
&w_id,
&authed.email,
lq.path_start.as_deref(),
)
.fetch_all(&db)
.await?;
for row in draft_only_rows {
let v: serde_json::Value =
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
// App / raw-app drafts are the bare editor value (`App` or
// `{files, runnables, …}`) — neither shape carries a `path`
// field. The editor writes a separate `draft_path` field
// into the draft JSON when (and only when) the typed path
// differs from the deployed one. `None` here = unchanged.
let draft_path = v
.get("draft_path")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != row.path.as_str())
.map(|s| s.to_string());
rows.push(ListableApp {
id: 0,
workspace_id: w_id.clone(),
path: row.path,
summary: v
.get("summary")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string(),
version: 0,
extra_perms: serde_json::Value::Object(serde_json::Map::new()),
execution_mode: String::new(),
starred: false,
edited_at: Some(row.created_at),
draft_only: Some(true),
deployment_msg: None,
raw_app: row.typ == "raw_app",
labels: None,
is_draft: true,
draft_path,
});
}
}
Ok(Json(rows))
}
@@ -567,12 +648,31 @@ async fn get_raw_app_data(
// Ok(Json(version))
// }
// Fields inlined rather than flattened from WithStarredInfoQuery /
// WithDraftQuery — see the same comment on `GetScriptByPathQuery` in
// scripts.rs: axum's `serde_urlencoded` query extractor doesn't preserve
// the "true"/"false" → bool conversion through `#[serde(flatten)]`.
#[derive(Deserialize)]
struct GetAppQuery {
with_starred_info: Option<bool>,
#[serde(default)]
get_draft: bool,
/// When no deployed app exists at this path and `get_draft` is set,
/// `raw_app` picks which draft kind to look up (`raw_app` or `app`).
/// Ignored when a deployed row exists — the row's own `raw_app`
/// column wins. Frontend sets this from the route the editor is on
/// (`/apps_raw/...` → true, `/apps/...` → false).
#[serde(default)]
raw_app: Option<bool>,
}
async fn get_app(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<WithStarredInfoQuery>,
) -> JsonResult<AppWithLastVersionAndStarred> {
Query(query): Query<GetAppQuery>,
) -> JsonResult<WithDraftOverlay> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
@@ -586,9 +686,9 @@ async fn get_app(
JOIN app_version
ON app_version.id = app.versions[array_upper(app.versions, 1)]
LEFT JOIN favorite
ON favorite.favorite_kind = 'app'
AND favorite.workspace_id = app.workspace_id
AND favorite.path = app.path
ON favorite.favorite_kind = 'app'
AND favorite.workspace_id = app.workspace_id
AND favorite.path = app.path
AND favorite.usr = $3
WHERE app.path = $1 AND app.workspace_id = $2",
)
@@ -612,8 +712,40 @@ async fn get_app(
};
tx.commit().await?;
let app = not_found_if_none(app_o, "App", path)?;
Ok(Json(app))
// Editors that have only ever drafted (never deployed) an app at this
// path will land here with no deployed row. When `get_draft` is set,
// fall back to the draft table so /apps/edit/draft_<uuid> and
// /apps_raw/edit/draft_<uuid> work the same way as a deployed reload.
// For draft-only there's no `raw_app` row column to consult — the
// caller's `raw_app` query param picks the draft kind.
let overlay = match app_o {
Some(app) => {
let kind = if app.app.raw_app {
UserDraftItemKind::RawApp
} else {
UserDraftItemKind::App
};
maybe_overlay_draft(&db, &w_id, &authed.email, kind, path, query.get_draft, app).await?
}
None if query.get_draft => {
let kind = if query.raw_app.unwrap_or(false) {
UserDraftItemKind::RawApp
} else {
UserDraftItemKind::App
};
fetch_draft_only(&db, &w_id, &authed.email, kind, path)
.await?
.ok_or_else(|| {
windmill_common::error::Error::NotFound(format!("App not found at path {path}"))
})?
}
None => {
return Err(windmill_common::error::Error::NotFound(format!(
"App not found at path {path}"
)));
}
};
Ok(Json(overlay))
}
async fn get_app_lite(
@@ -644,55 +776,6 @@ async fn get_app_lite(
Ok(Json(app))
}
async fn get_app_w_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<AppWithLastVersionAndDraft> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>(
r#"
SELECT
app.id,
app.path,
app.summary,
app.versions,
app.policy,
app.custom_path,
app.extra_perms,
app_version.value,
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
ON app_version.id = app.versions[array_upper(app.versions, 1)]
LEFT JOIN draft
ON app.path = draft.path
AND draft.workspace_id = $2
AND draft.typ = 'app'
WHERE app.path = $1
AND app.workspace_id = $2
"#,
)
.bind(path.to_owned())
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let app = not_found_if_none(app_o, "App", path)?;
Ok(Json(app))
}
async fn get_app_history(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -1580,8 +1663,10 @@ async fn delete_app(
.fetch_all(&mut *tx)
.await?;
// Cover both `app` and `raw_app` draft kinds — the `app` table backs
// both, and the old `typ = 'app'`-only clause leaked raw-app drafts.
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')",
path,
&w_id
)
@@ -3208,18 +3293,6 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
Ok((permissioned_as, email))
}
pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> {
return crate::users::require_is_writer(
authed,
path,
w_id,
db,
"SELECT extra_perms FROM app WHERE path = $1 AND workspace_id = $2",
"app",
)
.await;
}
async fn exists_app(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
+312 -97
View File
@@ -6,133 +6,348 @@
* LICENSE-AGPL for a copy of the license.
*/
use crate::{
db::{ApiAuthed, DB},
users::{maybe_refresh_folders, require_owner_of_path},
};
use crate::db::{ApiAuthed, DB};
use axum::{
extract::{Extension, Path},
routing::{delete, post},
routing::{get, post},
Json, Router,
};
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use windmill_common::{db::UserDB, error::Result, utils::StripPath};
use windmill_common::{
db::UserDB,
error::{Error, Result},
user_drafts::UserDraftItemKind,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/create", post(create_draft))
.route("/delete/{kind}/{*path}", delete(delete_draft))
.route("/get/{kind}/{*path}", get(get_draft_for_user))
.route("/save_draft/{kind}/{*path}", post(save_draft))
.route("/get_draft/{kind}/{*path}", get(get_draft))
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
#[sqlx(type_name = "DRAFT_TYPE", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum DraftType {
Script,
Flow,
App,
#[derive(Deserialize, Debug)]
pub struct SaveDraftRequest {
/// Draft content to save. `null` (or omitted) signals a delete — the
/// row is removed under the same conflict rules as an upsert.
#[serde(default)]
pub value: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
/// Server timestamp of the client's last known sync for this draft. When
/// present and `force` is false, the save is rejected if the server's
/// `created_at` is more recent (i.e. another writer moved the row
/// forward since this client last saw it). Omit on a first save.
#[serde(default)]
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
/// Skip the conflict check and unconditionally overwrite the server
/// copy. Use after the client has resolved the conflict locally.
#[serde(default)]
pub force: bool,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct Draft {
pub path: String,
pub value: sqlx::types::Json<Box<serde_json::value::Value>>,
pub typ: DraftType,
#[derive(Serialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum SaveDraftStatus {
Saved,
Conflict,
}
pub async fn require_writer_of_path(
authed: &ApiAuthed,
path: &str,
w_id: &str,
db: DB,
kind: &DraftType,
) -> Result<()> {
if authed.is_admin {
return Ok(());
} else if require_owner_of_path(authed, path).is_ok() {
return Ok(());
#[derive(Serialize, Debug)]
pub struct SaveDraftResponse {
pub status: SaveDraftStatus,
/// On `saved`: the timestamp at which the change was applied (the
/// client should remember it as the next `last_sync`). On `conflict`:
/// the existing row's `created_at`, so the client knows what the
/// server has.
pub current_timestamp: chrono::DateTime<chrono::Utc>,
}
/// Apply the current user's draft at (workspace, kind, path). With a
/// non-null `value` this upserts; with `null` (or omitted) it deletes.
/// Either way, the same conflict rule applies: when the existing row is
/// newer than `last_sync` (and `force` is false), the operation is
/// skipped and the response carries `status = conflict` + the server's
/// current timestamp so the client can rebase.
async fn save_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
Json(req): Json<SaveDraftRequest>,
) -> Result<Json<SaveDraftResponse>> {
let email = &authed.email;
let path = path.to_path();
let applied_at = if let Some(value) = &req.value {
// Upsert branch. Conflict check rides on a WHERE clause attached
// to DO UPDATE — when the existing row is newer than `last_sync`,
// the statement is a no-op and RETURNING yields nothing.
sqlx::query_scalar!(
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
VALUES ($1, $2, $3, $4, $5::text::json, now())
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = EXCLUDED.value, created_at = now()
WHERE $7::bool = true
OR $6::timestamptz IS NULL
OR draft.created_at <= $6::timestamptz
RETURNING created_at"#,
&w_id,
email,
path,
kind as UserDraftItemKind,
serde_json::to_string(value).unwrap(),
req.last_sync,
req.force,
)
.fetch_optional(&db)
.await?
} else {
match kind {
DraftType::Script => crate::scripts::require_is_writer(authed, path, w_id, db).await,
DraftType::Flow => crate::flows::require_is_writer(authed, path, w_id, db).await,
DraftType::App => crate::apps::require_is_writer(authed, path, w_id, db).await,
// Delete branch. Same conflict rule lifted into the WHERE clause.
// Returns NULL when the row was either too new (conflict) OR
// already absent (idempotent delete) — disambiguated below.
sqlx::query_scalar!(
r#"DELETE FROM draft
WHERE workspace_id = $1
AND email = $2
AND path = $3
AND typ = $4
AND ($6::bool = true
OR $5::timestamptz IS NULL
OR created_at <= $5::timestamptz)
RETURNING now() as "now!""#,
&w_id,
email,
path,
kind as UserDraftItemKind,
req.last_sync,
req.force,
)
.fetch_optional(&db)
.await?
};
if let Some(ts) = applied_at {
return Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: ts,
}));
}
// No row affected. Either:
// - the existing row was newer than `last_sync` (conflict), or
// - this was a delete request and no row existed (idempotent ok).
let existing = sqlx::query_scalar!(
r#"SELECT created_at FROM draft
WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4"#,
&w_id,
email,
path,
kind as UserDraftItemKind,
)
.fetch_optional(&db)
.await?;
match existing {
Some(ts) => Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Conflict,
current_timestamp: ts,
})),
// Delete + nothing-was-there ⇒ report success with server's NOW().
None => {
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
.fetch_one(&db)
.await?;
Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: now,
}))
}
}
}
async fn create_draft(
#[derive(Serialize, Debug)]
pub struct OwnDraft {
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
pub saved_at: chrono::DateTime<chrono::Utc>,
}
/// Fetch the current user's draft content at (kind, path). 404 if no draft.
async fn get_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
) -> Result<Json<OwnDraft>> {
let path = path.to_path();
let row = sqlx::query_as!(
OwnDraft,
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at as "saved_at!"
FROM draft
WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4"#,
&w_id,
&authed.email,
path,
kind as UserDraftItemKind,
)
.fetch_optional(&db)
.await?;
row.map(Json)
.ok_or_else(|| Error::NotFound(format!("no draft for current user at {path}")))
}
#[derive(Deserialize, Debug)]
pub struct GetDraftQuery {
/// Workspace username of the draft owner to fetch. Omit to fetch the
/// legacy workspace-level (NULL email) row, if any. Emails are not
/// part of the public draft API — the username is resolved to an
/// email server-side.
pub username: Option<String>,
}
#[derive(Serialize, Debug)]
pub struct DraftForUser {
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// Fetch a specific user's (or the legacy NULL row's) draft content at a
/// path. Used by the "other users' drafts" banner in editors after the
/// list of other owners has been surfaced on the deployed-overlay
/// response. The caller identifies the owner by workspace username so
/// emails never reach the client.
async fn get_draft_for_user(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(draft): Json<Draft>,
) -> Result<(StatusCode, String)> {
let authed = maybe_refresh_folders(&draft.path, &w_id, authed, &db).await;
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
axum::extract::Query(query): axum::extract::Query<GetDraftQuery>,
) -> Result<Json<DraftForUser>> {
let path = path.to_path();
require_can_read_path(&authed, &user_db, &w_id, kind, path).await?;
let mut tx = user_db.begin(&authed).await?;
// Username -> email lookup, scoped to the workspace. None signals
// "fetch the legacy NULL-email row" (kept distinct from a username
// that simply has no draft, which falls through to 404 below).
let owner_email: Option<String> = if let Some(username) = &query.username {
let email = sqlx::query_scalar!(
r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#,
&w_id,
username,
)
.fetch_optional(&db)
.await?;
match email {
Some(e) => Some(e),
None => {
return Err(Error::NotFound(format!(
"no user with username {username} in workspace"
)))
}
}
} else {
None
};
require_writer_of_path(&authed, &draft.path, &w_id, db, &draft.typ).await?;
sqlx::query!(
"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, created_at = now()",
let row = sqlx::query_as!(
DraftForUser,
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>", created_at
FROM draft
WHERE workspace_id = $1
AND path = $2
AND typ = $3
AND email IS NOT DISTINCT FROM $4"#,
&w_id,
draft.path,
//to preserve key orders
serde_json::to_string(&draft.value).unwrap(),
draft.typ as DraftType,
path,
kind as UserDraftItemKind,
owner_email,
)
.execute(&mut *tx)
.fetch_optional(&db)
.await?;
tx.commit().await?;
Ok((StatusCode::CREATED, format!("draft {} created", draft.path)))
row.map(Json).ok_or_else(|| {
Error::NotFound(format!(
"no draft for {} at {path}",
query.username.as_deref().unwrap_or("<legacy>")
))
})
}
async fn delete_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, kind, path)): Path<(String, DraftType, StripPath)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3",
path.to_path(),
kind as DraftType,
w_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(format!("deleted draft"))
/// Each `UserDraftItemKind` maps to either its own table (where RLS can
/// resolve item-level extra_perms grants that bypass folder/owner checks)
/// or `None` (kinds without a backing table fall through to the path-only
/// access check below).
fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> {
use UserDraftItemKind::*;
match kind {
Script => Some("script"),
Flow => Some("flow"),
App | RawApp => Some("app"),
Resource => Some("resource"),
Variable => Some("variable"),
TriggerSchedule => Some("schedule"),
TriggerHttp => Some("http_trigger"),
TriggerWebsocket => Some("websocket_trigger"),
TriggerPostgres => Some("postgres_trigger"),
TriggerKafka => Some("kafka_trigger"),
TriggerNats => Some("nats_trigger"),
TriggerMqtt => Some("mqtt_trigger"),
TriggerSqs => Some("sqs_trigger"),
TriggerGcp => Some("gcp_trigger"),
TriggerAzure => Some("azure_trigger"),
TriggerEmail | TriggerDefaultEmail => Some("email_trigger"),
TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle | TriggerGithub => {
Some("native_trigger")
}
// trigger_webhook is a property of script/flow rows, not its own row.
TriggerWebhook => None,
}
}
// async fn get_draft(
// authed: ApiAuthed,
// Extension(user_db): Extension<UserDB>,
// Path((w_id, path)): Path<(String, StripPath)>,
// ) -> JsonResult<Draft> {
// let path = path.to_path();
// let mut tx = user_db.begin(&authed).await?;
// let script_o = sqlx::query_as!(
// Draft,
// r#"SELECT path, value, typ as "typ: DraftType" FROM draft WHERE path = $1 AND workspace_id = $2"#,
// path,
// w_id
// )
// .fetch_optional(&mut *tx)
// .await?;
// tx.commit().await?;
// let draft = not_found_if_none(script_o, "draft", path)?;
// Ok(Json(draft))
// }
/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers, in
/// order of cheapness:
/// 1. admin → always.
/// 2. Path-prefix match against the user's own namespace (`u/{username}`)
/// or any folder in `authed.folders` (which is the precomputed read
/// set used to seed UserDB's session context, so groups + direct
/// grants on the folder are already factored in).
/// 3. RLS-aware `SELECT 1` against the kind's backing table — covers
/// item-level extra_perms grants that bypass folder/owner checks.
/// Both "not readable" and "doesn't exist" return a 404 — we don't leak
/// path existence to non-readers.
async fn require_can_read_path(
authed: &ApiAuthed,
user_db: &UserDB,
w_id: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<()> {
if authed.is_admin {
return Ok(());
}
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 2 {
match parts[0] {
"u" if parts[1] == authed.username => return Ok(()),
"f" => {
let folder = parts[1];
if authed.folders.iter().any(|(name, _, _)| name == folder) {
return Ok(());
}
}
_ => {}
}
}
if let Some(table) = table_for_kind(kind) {
let mut tx = user_db.clone().begin(authed).await?;
let query = format!("SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1");
let row = sqlx::query_scalar::<_, i32>(&query)
.bind(path)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
if row.is_some() {
return Ok(());
}
}
Err(Error::NotFound(format!("no draft visible at {path}")))
}
+2 -2
View File
@@ -77,8 +77,8 @@ mod capture;
mod concurrency_groups;
mod db;
mod db_health;
mod drafts;
#[cfg(feature = "private")]
pub mod ee;
pub mod ee_oss;
@@ -553,8 +553,8 @@ pub async fn run_server(
"/concurrency_groups",
concurrency_groups::workspaced_service(),
)
.nest("/embeddings", embeddings::workspaced_service())
.nest("/drafts", drafts::workspaced_service())
.nest("/embeddings", embeddings::workspaced_service())
.nest("/favorites", favorite::workspaced_service())
.nest("/flows", flows::workspaced_service())
.nest(
-1
View File
@@ -13,7 +13,6 @@ pub use windmill_api_auth::{check_scopes, require_devops_role, require_super_adm
#[cfg(feature = "private")]
pub use windmill_common::usernames::generate_instance_wide_unique_username;
pub use windmill_common::utils::WithStarredInfoQuery;
#[cfg(feature = "enterprise")]
pub use windmill_alerting::{
@@ -282,7 +282,6 @@ where
"edited_by",
"permissioned_as",
"archived",
"has_draft",
"error",
"last_server_ping",
"server_id",
+1
View File
@@ -102,6 +102,7 @@ pub mod teams_oss;
pub mod tracing_init;
pub mod trashbin;
pub mod triggers;
pub mod user_drafts;
pub mod usernames;
pub mod users;
pub mod utils;
+315
View File
@@ -0,0 +1,315 @@
/*
* Author: Diego Imbert
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Shared types and helpers for the per-user `draft` table.
//!
//! Lives in `windmill-common` so each entity crate (`windmill-api-scripts`,
//! `windmill-api-flows`, the trigger crates, etc.) can pull the helper
//! directly without taking a dependency on the top-level `windmill-api`
//! crate. Keep this file tiny and free of HTTP/axum concerns.
use crate::db::DB;
use crate::error::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Closed set of item kinds a user can have an autosaved draft on. Mirrors
/// the frontend's `USER_DRAFT_ITEM_KINDS`; the Postgres `DRAFT_KIND` enum
/// must stay in lockstep — adding a kind requires both a new variant here
/// and an `ALTER TYPE ... ADD VALUE` migration.
///
/// `snake_case` matches the wire/DB encoding so the same string round-trips
/// through HTTP path params, JSON bodies, and the `draft.typ` column without
/// per-edge mapping.
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum UserDraftItemKind {
Script,
Flow,
App,
RawApp,
Resource,
Variable,
TriggerSchedule,
TriggerWebhook,
TriggerDefaultEmail,
TriggerEmail,
TriggerHttp,
TriggerWebsocket,
TriggerPostgres,
TriggerKafka,
TriggerNats,
TriggerMqtt,
TriggerSqs,
TriggerGcp,
TriggerAzure,
TriggerPoll,
TriggerCli,
TriggerNextcloud,
TriggerGoogle,
TriggerGithub,
}
/// Query-string flag accepted by every "get by path" route that supports
/// the draft overlay. Compose into a route-specific query struct via
/// `#[serde(flatten)]` when the route already has other query fields.
#[derive(Debug, Deserialize, Default)]
pub struct WithDraftQuery {
/// When true, attach the authed user's draft for this entity (if any)
/// as a separate `draft` field on the response. Defaults to false so
/// non-editor callers see the deployed shape unchanged.
#[serde(default)]
pub get_draft: bool,
}
/// Response wrapper that sends the deployed entity untouched and attaches
/// the authed user's draft (if any) as a sibling `draft` field — the
/// frontend pairs the two to diff/restore/discard.
///
/// Wire shape is `<deployed fields...> + is_draft + draft_saved_at? +
/// no_deployed? + draft?` — non-editor callers ignore the overlay fields
/// and keep getting the deployed shape they used to. The deployed and
/// the draft are NEVER merged on the server; the editor's saved shape
/// can diverge from the deployed shape arbitrarily, so any per-kind
/// translation lives in the frontend loader where the types are known.
///
/// `inner` is held as `serde_json::Value` so the caller only needs
/// `Serialize` on its response type — most read-only response shapes
/// (e.g. `ScriptWithStarred`) only derive `Serialize`, and requiring
/// `DeserializeOwned` would force derive cascades through many crates.
/// One row of `other_drafts_users` — represents a draft on the same path
/// owned by someone other than the authed user. `username` is `None` for
/// the legacy NULL-email row (workspace-scoped pre-migration draft), which
/// the frontend surfaces as a "Legacy draft" entry with an info tooltip.
#[derive(Debug, Serialize)]
pub struct OtherDraftUser {
/// `None` represents a legacy workspace-level draft (no owner).
pub username: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WithDraftOverlay {
#[serde(flatten)]
pub inner: serde_json::Value,
pub is_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_saved_at: Option<DateTime<Utc>>,
/// True when no deployed row exists at this path — the response
/// body is a best-effort stand-in synthesized from the draft, and
/// only `draft` is canonical. Frontend uses this to disable "diff
/// vs deployed" / "reset to deployed" actions and to skip its
/// own deployed-shape parsing of `inner`. Omitted when false.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub no_deployed: bool,
/// The user's saved draft payload (whatever shape the editor wrote).
/// Present when `get_draft=true` and a draft exists. Pair with the
/// deployed (the rest of the response) for diff/restore UI.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<serde_json::Value>,
/// Other users with a draft on the same path (excludes the authed
/// user). Frontend surfaces this list in a banner so the user can
/// view another's JSON or fork it. Empty list is omitted to keep
/// the common-case response lean.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub other_drafts_users: Vec<OtherDraftUser>,
}
/// List every other user (and the legacy NULL-email row, if any) that
/// has a draft at `(workspace, kind, path)`. Returns usernames only —
/// emails never leave the server. LEFT JOIN against `usr` so an
/// orphaned draft (user removed from the workspace) still surfaces, with
/// its `username` falling back to `None` rather than dropping the row.
/// The authed user is excluded via `email <> authed_email`; the legacy
/// row matches because `email IS NULL` fails that comparison.
async fn fetch_other_drafts_users(
db: &DB,
w_id: &str,
authed_email: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<Vec<OtherDraftUser>> {
let rows = sqlx::query_as!(
OtherDraftUser,
r#"SELECT u.username as "username?"
FROM draft d
LEFT JOIN usr u
ON u.workspace_id = d.workspace_id
AND u.email = d.email
WHERE d.workspace_id = $1
AND d.path = $2
AND d.typ = $3
AND (d.email IS NULL OR d.email <> $4)
ORDER BY d.email NULLS LAST"#,
w_id,
path,
kind as UserDraftItemKind,
authed_email,
)
.fetch_all(db)
.await?;
Ok(rows)
}
/// If `get_draft` is true AND the authed user has a draft saved for
/// `(workspace, kind, path)`, attach it as `draft` on the response.
/// The deployed payload (`deployed`) is always serialized into `inner`
/// untouched — the wire response is `<deployed fields...> + is_draft +
/// draft? + draft_saved_at? + other_drafts_users?` regardless of whether
/// the authed user has a draft.
pub async fn maybe_overlay_draft<T>(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
path: &str,
get_draft: bool,
deployed: T,
) -> Result<WithDraftOverlay>
where
T: serde::Serialize,
{
let inner = serde_json::to_value(&deployed)?;
if !get_draft {
return Ok(WithDraftOverlay {
inner,
is_draft: false,
draft_saved_at: None,
no_deployed: false,
draft: None,
other_drafts_users: Vec::new(),
});
}
let row = sqlx::query!(
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at
FROM draft
WHERE workspace_id = $1
AND email = $2
AND path = $3
AND typ = $4"#,
w_id,
email,
path,
kind as UserDraftItemKind,
)
.fetch_optional(db)
.await?;
let other_drafts_users = fetch_other_drafts_users(db, w_id, email, kind, path).await?;
let Some(row) = row else {
return Ok(WithDraftOverlay {
inner,
is_draft: false,
draft_saved_at: None,
no_deployed: false,
draft: None,
other_drafts_users,
});
};
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
Ok(WithDraftOverlay {
inner,
is_draft: true,
draft_saved_at: Some(row.created_at),
no_deployed: false,
draft: Some(draft_json),
other_drafts_users,
})
}
/// Delete the authed user's draft for `(workspace, kind, path)`.
/// Idempotent — returns Ok even when no row exists. Scoped to a single
/// email so other users' drafts at the same path are untouched.
///
/// Called from item delete handlers (`delete_script_by_path`,
/// `delete_flow_by_path`, etc.) so the user can't be left with a stale
/// per-user draft after the underlying item is gone.
pub async fn delete_user_draft(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<()> {
sqlx::query!(
r#"DELETE FROM draft
WHERE workspace_id = $1
AND email = $2
AND path = $3
AND typ = $4"#,
w_id,
email,
path,
kind as UserDraftItemKind,
)
.execute(db)
.await?;
Ok(())
}
/// Fetch the authed user's draft as a standalone payload, used by
/// "get by path" routes when no deployed row exists at the path but a
/// draft might. Returns the draft as `WithDraftOverlay` with both
/// `inner` (best-effort stand-in for the missing deployed) and `draft`
/// populated to the same JSON, and `no_deployed = true` so the frontend
/// knows there's no real deployed to compare against.
///
/// Callers must already have established that no deployed row exists.
/// Returns `Ok(None)` when there's also no draft — caller should 404.
///
/// The draft JSON is expected to be a JSON object (every editor writes
/// drafts as object-shaped editable state, so `serde(flatten)` works on
/// the inner value). A non-object draft would render with no fields
/// flattened — defensive but degraded.
pub async fn fetch_draft_only(
db: &DB,
w_id: &str,
email: &str,
kind: UserDraftItemKind,
path: &str,
) -> Result<Option<WithDraftOverlay>> {
let row = sqlx::query!(
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at
FROM draft
WHERE workspace_id = $1
AND email = $2
AND path = $3
AND typ = $4"#,
w_id,
email,
path,
kind as UserDraftItemKind,
)
.fetch_optional(db)
.await?;
let Some(row) = row else {
return Ok(None);
};
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
let other_drafts_users = fetch_other_drafts_users(db, w_id, email, kind, path).await?;
Ok(Some(WithDraftOverlay {
// Best-effort stand-in for the missing deployed — same JSON as
// `draft`. Frontend should read `.draft` for the editor state
// and skip "diff vs deployed" UI when `no_deployed` is set.
inner: draft_json.clone(),
is_draft: true,
draft_saved_at: Some(row.created_at),
no_deployed: true,
draft: Some(draft_json),
other_drafts_users,
}))
}
+30 -2
View File
@@ -43,6 +43,9 @@ use windmill_common::{
db::{DbWithOptAuthed, UserDB},
error::{self, Error, JsonResult, Result},
get_database_url,
user_drafts::{
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath},
variables,
worker::{CLOUD_HOSTED, WINDMILL_DIR},
@@ -360,7 +363,8 @@ async fn get_resource(
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<ListableResource> {
Query(q): Query<WithDraftQuery>,
) -> JsonResult<WithDraftOverlay> {
let path = path.to_path();
check_scopes(&authed, || format!("resources:read:{}", path))?;
let mut tx = user_db.begin(&authed).await?;
@@ -391,7 +395,17 @@ async fn get_resource(
explain_resource_perm_error(&path, &w_id, &db, &authed).await?;
}
let resource = not_found_if_none(resource_o, "Resource", path)?;
Ok(Json(resource))
let overlay = maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Resource,
path,
q.get_draft,
resource,
)
.await?;
Ok(Json(overlay))
}
async fn exists_resource(
@@ -1158,6 +1172,20 @@ async fn delete_resource(
.await?;
tx.commit().await?;
// Clean up the authed user's per-user drafts for this resource path
// and any linked variables we cascaded into. Idempotent on no-draft.
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await?;
for var_path in &deleted_linked_variables {
delete_user_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Variable,
var_path,
)
.await?;
}
handle_deployment_metadata(
&authed.email,
&authed.username,
+28 -2
View File
@@ -35,6 +35,7 @@ use windmill_common::{
db::{DbWithOptAuthed, UserDB},
error::{Error, JsonResult, Result},
scripts::ScriptHash,
user_drafts::{delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay},
utils::{not_found_if_none, paginate, Pagination, StripPath, WarnAfterExt},
variables::{
build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable,
@@ -205,10 +206,16 @@ async fn list_variables(
Ok(Json(rows))
}
// `get_draft` inlined rather than flattened from WithDraftQuery — see
// the same comment on `GetScriptByPathQuery` in scripts.rs: axum's
// `serde_urlencoded` query extractor doesn't preserve the "true"/"false"
// → bool conversion through `#[serde(flatten)]`.
#[derive(Deserialize)]
struct GetVariableQuery {
decrypt_secret: Option<bool>,
include_encrypted: Option<bool>,
#[serde(default)]
get_draft: bool,
}
async fn get_variable(
@@ -217,7 +224,7 @@ async fn get_variable(
Extension(db): Extension<DB>,
Query(q): Query<GetVariableQuery>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<ListableVariable> {
) -> JsonResult<WithDraftOverlay> {
let path = path.to_path();
check_scopes(&authed, || format!("variables:read:{}", path))?;
@@ -304,7 +311,17 @@ async fn get_variable(
variable
};
Ok(Json(r))
let overlay = maybe_overlay_draft(
&db,
&w_id,
&authed.email,
UserDraftItemKind::Variable,
path,
q.get_draft,
r,
)
.await?;
Ok(Json(overlay))
}
#[derive(Deserialize)]
@@ -641,6 +658,15 @@ async fn delete_variable(
tx.commit().await?;
// Clean up the authed user's per-user drafts for this path so they
// aren't left dangling after the underlying item is gone. Idempotent
// on the no-draft case. Resource is included because variables
// cascade-delete linked resource rows at the same path.
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Variable, path).await?;
if deleted_linked_resource.is_some() {
delete_user_draft(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await?;
}
// If variable was a secret, also delete from Vault backend (if configured)
if is_secret {
delete_secret_from_backend(&db, &w_id, path).await?;
+54 -2
View File
@@ -16,6 +16,9 @@ use windmill_api_auth::{check_scopes, ApiAuthed};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
user_drafts::{
delete_user_draft, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, WithDraftQuery,
},
utils::{paginate, Pagination, StripPath},
worker::CLOUD_HOSTED,
DB,
@@ -106,6 +109,29 @@ pub trait TriggerCrud: Send + Sync + 'static {
&Self::ROUTE_PREFIX[1..]
}
/// `UserDraftItemKind` for the per-user `draft` table lookup. Defaults
/// to derive from `TRIGGER_TYPE` so each impl gets it for free as long
/// as the string matches the canonical kind (e.g. `"http"` →
/// `TriggerHttp`). Override only when the mapping isn't 1:1.
fn user_draft_item_kind() -> UserDraftItemKind {
match Self::TRIGGER_TYPE {
"http" => UserDraftItemKind::TriggerHttp,
"websocket" => UserDraftItemKind::TriggerWebsocket,
"kafka" => UserDraftItemKind::TriggerKafka,
"nats" => UserDraftItemKind::TriggerNats,
"sqs" => UserDraftItemKind::TriggerSqs,
"mqtt" => UserDraftItemKind::TriggerMqtt,
"gcp" => UserDraftItemKind::TriggerGcp,
"azure" => UserDraftItemKind::TriggerAzure,
"postgres" => UserDraftItemKind::TriggerPostgres,
"email" => UserDraftItemKind::TriggerEmail,
other => panic!(
"TriggerCrud impl with TRIGGER_TYPE = {:?} must override user_draft_item_kind()",
other
),
}
}
async fn create_trigger(
&self,
db: &DB,
@@ -551,8 +577,10 @@ async fn get_trigger<T: TriggerCrud>(
Extension(handler): Extension<Arc<T>>,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((workspace_id, path)): Path<(String, StripPath)>,
) -> JsonResult<T::Trigger> {
Query(q): Query<WithDraftQuery>,
) -> JsonResult<WithDraftOverlay> {
let path = path.to_path();
check_scopes(&authed, || {
format!("{}:read:{}", T::scope_domain_name(), &path)
@@ -565,7 +593,17 @@ async fn get_trigger<T: TriggerCrud>(
tx.commit().await?;
Ok(Json(trigger))
let overlay = maybe_overlay_draft(
&db,
&workspace_id,
&authed.email,
T::user_draft_item_kind(),
path,
q.get_draft,
trigger,
)
.await?;
Ok(Json(overlay))
}
async fn update_trigger<T: TriggerCrud>(
@@ -691,6 +729,7 @@ async fn delete_trigger<T: TriggerCrud>(
Extension(handler): Extension<Arc<T>>,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((workspace_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
let path = path.to_path();
@@ -748,6 +787,19 @@ async fn delete_trigger<T: TriggerCrud>(
tx.commit().await?;
// Clean up the authed user's per-user draft for this trigger path.
// The draft kind is derived from the impl via TriggerCrud, mirroring
// the lookup `maybe_overlay_draft` uses on get-by-path. Idempotent on
// no-draft.
delete_user_draft(
&db,
&workspace_id,
&authed.email,
T::user_draft_item_kind(),
path,
)
.await?;
Ok(format!("Trigger '{}' deleted", path))
}
+14 -1
View File
@@ -79,7 +79,6 @@ pub struct ListableFlow {
pub archived: bool,
pub extra_perms: serde_json::Value,
pub starred: bool,
pub has_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -89,6 +88,20 @@ pub struct ListableFlow {
pub deployment_msg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
/// True when the authed user has a draft for this flow — either the
/// row is draft-only (no deployed flow at this path) or the user has
/// saved a per-user draft on top of the deployed row.
#[serde(default)]
pub is_draft: bool,
/// User-typed path the editor has staged but not yet deployed —
/// sourced from the draft JSON's `draft_path` field, which the flow
/// editor only writes when the typed path differs from the deployed
/// one. Lets the home list render the meaningful name instead of
/// the autogenerated `u/{user}/draft_{uuid}` URL path. `None` when
/// unchanged (which is most rows).
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
+17 -2
View File
@@ -446,8 +446,6 @@ pub struct ListableScript {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_draft: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
pub has_deploy_errors: bool,
pub ws_error_handler_muted: Option<bool>,
@@ -461,6 +459,23 @@ pub struct ListableScript {
pub kind: ScriptKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
/// `true` when this entry represents the authed user's draft —
/// either because no deployed row exists at this path (draft-only),
/// or because a deployed row exists *and* the user has saved a draft
/// on top of it. Distinguishes user-mode-only state from team state.
#[serde(skip_serializing_if = "is_false")]
pub is_draft: bool,
/// User-typed path the editor has staged but not yet deployed.
/// Surfaced for draft-only rows so the home list can render the
/// meaningful name instead of the autogenerated
/// `u/{user}/draft_{uuid}` URL path. Sourced from the draft JSON
/// (scripts: `value.path` — the editor binds the Path widget
/// directly to `script.path`; flows/apps/raw apps: an explicit
/// `value.draft_path` field the editor only writes when the typed
/// path differs from the deployed one). `None` when unchanged.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
}
fn is_false(x: &bool) -> bool {
+145
View File
@@ -0,0 +1,145 @@
/**
* Per-key coalescing async runner.
*
* For each key, at most one task is running and at most one is pending.
* `submit(key, fn)` runs `fn` immediately when the key is idle; otherwise
* `fn` REPLACES any previously-pending task for that key (the displaced
* one is dropped and never runs). When the running task settles, the
* pending task if any starts next. Different keys are independent.
*
* Use to collapse bursts of "save the latest version" calls down to
* exactly two runs: the one in flight plus the most recent.
*/
import { SvelteSet } from 'svelte/reactivity'
export type CoalescingTask<T = unknown> = () => T | Promise<T>
/** Thrown into the rejection of a `submitAndWait` promise (and a
* `cancel`-dropped pending task) when the task is discarded before it
* had a chance to run. Fire-and-forget `submit` callers don't see this
* only awaiters do. */
export class CoalescingDisplacedError extends Error {
constructor() {
super('coalescingRunner: pending task displaced before it could run')
this.name = 'CoalescingDisplacedError'
}
}
export type CoalescingKeyedRunner = {
/** Fire-and-forget. Schedule `fn` under `key` per the policy above.
* Synchronous; `fn` is invoked on the current tick when the key is
* idle. If a previously-submitted task is still pending for this
* key, it is silently dropped. */
submit(key: string, fn: CoalescingTask): void
/** Same scheduling as `submit`, but returns a promise that resolves
* with `fn`'s return value when `fn` actually runs, rejects with
* `fn`'s throw if `fn` fails, or rejects with `CoalescingDisplacedError`
* if this submission is dropped (by `cancel`, or by a later
* `submit` / `submitAndWait` for the same key) before it runs. */
submitAndWait<T>(key: string, fn: CoalescingTask<T>): Promise<T>
/** Drop the pending (queued) task for `key` without running it.
* Returns true if there was something to cancel. Does NOT affect
* any task currently in flight there's no way to abort it. If
* the dropped task was submitted via `submitAndWait`, its promise
* rejects with `CoalescingDisplacedError`. */
cancel(key: string): boolean
/** Reactively whether a task for `key` is currently in flight (the
* chain is running). Backed by a `SvelteSet`, so reading this inside a
* `$derived` / `$effect` re-runs when the key starts/stops running. */
isRunning(key: string): boolean
}
type PendingTask = {
fn: CoalescingTask
resolve?: (value: unknown) => void
reject?: (reason: unknown) => void
}
type Entry = { pending: PendingTask | undefined }
/**
*
* @example
* const runner = createCoalescingKeyedRunner()
* // f, g, h are async functions
* runner.submit('key1', f) // run is synchronous but f is async. Here f is ran immediately.
* runner.submit('key1', g) // f is still running: g is postponed
* runner.submit('key2', someFn) // someFn runs immediately (different key, unrelated to the rest)
* runner.submit('key1', h) // f is still running: g is discarded, h is postponed
* // A while later: f finished : h runs now
*/
export function createCoalescingKeyedRunner(): CoalescingKeyedRunner {
const state = new Map<string, Entry>()
// Reactive mirror of the keys with a chain currently running. Updated
// in lock-step with `state`'s lifetime (added when a chain starts in
// `setOrDisplace`, removed when it drains in `chain`). A `SvelteSet`
// gives per-key subscriptions for `isRunning`.
const runningKeys = new SvelteSet<string>()
async function chain(key: string, first: PendingTask): Promise<void> {
let current: PendingTask | undefined = first
while (current) {
try {
const result = await current.fn()
current.resolve?.(result)
} catch (e) {
// Don't kill the chain on a task failure — bursty callers
// rely on later submissions still running. submitAndWait
// callers see the error via their promise; fire-and-forget
// submit callers get a console.error so the failure isn't
// silently swallowed.
if (current.reject) current.reject(e)
else console.error('coalescingRunner: task failed', e)
}
const entry = state.get(key)!
current = entry.pending
entry.pending = undefined
}
state.delete(key)
runningKeys.delete(key)
}
/** Set `task` as the pending entry for `key`, displacing whatever
* was there (and rejecting its promise if it had one). If the key
* is idle, set up the entry and start the chain. */
function setOrDisplace(key: string, task: PendingTask): void {
const entry = state.get(key)
if (entry) {
entry.pending?.reject?.(new CoalescingDisplacedError())
entry.pending = task
return
}
state.set(key, { pending: undefined })
runningKeys.add(key)
void chain(key, task)
}
function submit(key: string, fn: CoalescingTask): void {
setOrDisplace(key, { fn })
}
function submitAndWait<T>(key: string, fn: CoalescingTask<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
setOrDisplace(key, {
fn: fn as CoalescingTask,
resolve: resolve as (v: unknown) => void,
reject
})
})
}
function cancel(key: string): boolean {
const entry = state.get(key)
if (!entry?.pending) return false
const dropped = entry.pending
entry.pending = undefined
dropped.reject?.(new CoalescingDisplacedError())
return true
}
function isRunning(key: string): boolean {
return runningKeys.has(key)
}
return { submit, submitAndWait, cancel, isRunning }
}
@@ -0,0 +1,144 @@
<script lang="ts">
import { untrack } from 'svelte'
import { CloudCheck, RefreshCcw, RotateCcw } from 'lucide-svelte'
import type { UserDraftItemKind } from '$lib/gen'
import { UserDraftDbSyncer, type UserDraftSyncState } from '$lib/userDraftDbSyncer.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { runResetToDeployed } from '$lib/userDraftToast'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
let {
workspace,
itemKind,
path,
// Reactive — when true, the indicator's popover hides "Reset to
// deployed" because there's nothing to fall back to (the editor
// is on a per-user draft at a path with no deployed row). Routes
// thread their own `isNewX` / `savedX.no_deployed` here.
draftOnly = false,
// Route-specific reset logic. The popover button stops sync,
// fires `value: null` at the syncer, awaits this, then restarts
// sync after two ticks — mirrors `notifyDraftLoaded`'s "Reset to
// deployed" toast action so the discard sticks.
onResetToDeployed
}: {
workspace: string
itemKind: UserDraftItemKind
path: string
draftOnly?: boolean
onResetToDeployed?: () => void | Promise<void>
} = $props()
// `UserDraft.has` reads `entry.state.val` (a $state), so the $derived
// re-runs when the in-memory draft appears / disappears — flips on
// after the first edit and back off after a successful reset.
const hasDraft = $derived(UserDraft.has(itemKind, path, { workspace }))
// Recompute the handle when the target draft changes; its `.state` getter
// is itself reactive to the autosave pipeline, so `syncState` tracks both.
const handle = $derived(UserDraftDbSyncer.getState({ workspace, itemKind, path }))
const syncState: UserDraftSyncState = $derived(handle.state)
// The "Saved" label is shown only for a few seconds after a save actually
// completes (saving → none). Other transitions to `none` (e.g. a discarded
// pending change) leave just the idle cloud-check icon with no label.
const SAVED_LABEL_MS = 3000
let savedVisible = $state(false)
let prev: UserDraftSyncState = 'none'
let timer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
const s = syncState
untrack(() => {
if (s === 'saving' || s === 'pending') {
// Active work — drop any lingering "Saved" label.
if (timer) {
clearTimeout(timer)
timer = undefined
}
savedVisible = false
} else if (s === 'none' && prev === 'saving') {
// A save just landed: flash "Saved" for SAVED_LABEL_MS.
savedVisible = true
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
savedVisible = false
timer = undefined
}, SAVED_LABEL_MS)
}
prev = s
})
})
$effect(() => {
return () => {
if (timer) clearTimeout(timer)
}
})
const label = $derived(
syncState === 'saving' || syncState === 'pending' ? 'Saving...' : savedVisible ? 'Saved' : ''
)
const showResetAction = $derived(!draftOnly && hasDraft && !!onResetToDeployed)
let popoverOpen = $state(false)
let resetting = $state(false)
async function resetToDeployed() {
if (!onResetToDeployed || resetting) return
resetting = true
try {
await runResetToDeployed({ workspace, itemKind, path, onResetToDeployed })
} finally {
resetting = false
popoverOpen = false
}
}
</script>
<div
class="flex items-center gap-1.5 text-primary min-w-[4.2rem]"
aria-label="Autosave status"
>
<Popover
bind:isOpen={popoverOpen}
placement="bottom-end"
usePointerDownOutside
closeOnOutsideClick
>
{#snippet trigger()}
<div class='rounded-md p-1.5 hover:bg-surface-hover cursor-pointer'>
{#if syncState === 'saving' || syncState === 'pending'}
<RefreshCcw size={14} class="animate-spin" />
{:else}
<CloudCheck size={16} />
{/if}
</div>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-3 text-sm w-72 p-3">
<p class="text-primary text-sm">
All changes are saved as a draft on the server. The draft is per-user — your teammates'
editors keep their own.
</p>
{#if showResetAction}
<Button
variant="default"
size="xs"
loading={resetting}
startIcon={{ icon: RotateCcw }}
on:click={() => void resetToDeployed()}
>
Reset to deployed
</Button>
{/if}
</div>
{/snippet}
</Popover>
{#if label}
<span class="text-secondary text-2xs">{label}</span>
{/if}
</div>
@@ -363,11 +363,7 @@
>
Open
</Button>
<Button
href={`/apps/edit/${item.path}?no_draft=true`}
target="_blank"
startIcon={{ icon: Edit }}
>
<Button href={`/apps/edit/${item.path}`} target="_blank" startIcon={{ icon: Edit }}>
Edit
</Button>
{/snippet}
@@ -87,7 +87,7 @@
| {
mode: 'normal'
deployed: Value
draft: Value | undefined
draft?: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
button?: { text: string; onClick: () => void }
+12 -3
View File
@@ -1,16 +1,25 @@
<script lang="ts">
// Renders a small status pill on home-page rows when the authed user
// has a per-user draft on the entity. `is_draft` is the per-user
// signal that replaced main's workspace-wide `has_draft` (the field
// rename mirrors the get-by-path overlay's `is_draft` flag).
//
// draft_only=true → "Draft only" (entity has never been deployed)
// draft_only=false → "+Draft" (deployed and user has a draft on top)
//
// Nothing renders when `is_draft` is false.
import Popover from './Popover.svelte'
import { Badge } from './common'
interface Props {
has_draft?: boolean
is_draft?: boolean
draft_only?: boolean
}
let { has_draft = false, draft_only = false }: Props = $props()
let { is_draft = false, draft_only = false }: Props = $props()
</script>
{#if has_draft}
{#if is_draft}
{#if draft_only}
<Popover notClickable>
{#snippet text()}
+49 -189
View File
@@ -2,7 +2,6 @@
import {
FlowService,
type Flow,
DraftService,
type PathScript,
type OpenFlow,
type InputTransform,
@@ -13,7 +12,6 @@
import { initHistory, redo, undo } from '$lib/history.svelte'
import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores'
import {
cleanValueProperties,
generateRandomString,
orderedJsonStringify,
readFieldsRecursively,
@@ -29,7 +27,7 @@
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { createRawSnippet, onMount, setContext, untrack } from 'svelte'
import { createRawSnippet, setContext, untrack } from 'svelte'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Button } from './common'
@@ -46,7 +44,6 @@
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
import { cleanFlow } from './flows/utils.svelte'
import {
Save,
DiffIcon,
HistoryIcon,
FileJson,
@@ -71,18 +68,15 @@
import { tutorialsToDo } from '$lib/stores'
import { getTutorialIndex } from '$lib/tutorials/config'
import EditorHeader from './EditorHeader.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
import { type TriggerContext, type ScheduleTrigger } from './triggers'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import {
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import type { Trigger } from './triggers/utils'
import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import { StepsInputArgs } from './flows/stepsInputArgs.svelte'
@@ -118,22 +112,18 @@
disabledFlowInputs = false,
savedPrimarySchedule = undefined,
version = undefined,
setSavedraftCb = undefined,
draftTriggersFromUrl = undefined,
selectedTriggerIndexFromUrl = undefined,
children,
loadedFromHistoryFromUrl,
noInitial = false,
liveEditorDraftStoragePath = undefined,
onSaveInitial,
onSaveDraft,
onDeploy,
onDeployError,
onDetails,
onSaveDraftError,
onSaveDraftOnlyAtNewPath,
onHistoryRestore,
onNavigate
onNavigate,
onResetToDeployed
}: FlowBuilderProps = $props()
let initialPathStore = writable(initialPath)
@@ -261,128 +251,9 @@
}
let loadingSave = $state(false)
let loadingDraft = $state(false)
export async function saveDraft(forceSave = false): Promise<void> {
withAIChangesWarning(async () => {
await saveDraftInternal(forceSave)
})
}
async function saveDraftInternal(forceSave = false): Promise<void> {
if (!newFlow && !savedFlow) {
return
}
if (savedFlow) {
const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow)
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties(
$state.snapshot({
...flowStore.val,
path: $pathStore,
draft_triggers: currentDraftTriggers
})
)
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraftInternal(true)
}
}
])
return
}
}
loadingDraft = true
try {
const flow = cleanFlow(flowStore.val)
if (newFlow || savedFlow?.draft_only) {
if (savedFlow?.draft_only) {
await FlowService.deleteFlowByPath({
workspace: $workspaceStore!,
path: initialPath,
keepCaptures: true
})
}
if (!initialPath || $pathStore != initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
path: initialPath || fakeInitialPath,
requestBody: {
new_path: $pathStore
},
runnableKind: 'flow'
})
}
await FlowService.createFlow({
workspace: $workspaceStore!,
requestBody: {
path: $pathStore,
summary: flow.summary ?? '',
description: flow.description ?? '',
value: flow.value,
schema: flow.schema,
tag: flow.tag,
draft_only: true,
ws_error_handler_muted: flow.ws_error_handler_muted,
visible_to_runner_only: flow.visible_to_runner_only,
on_behalf_of_email: flow.on_behalf_of_email,
labels: (flow as any).labels
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath,
typ: 'flow',
value: {
...flow,
path: $pathStore,
draft_triggers: triggersState.getDraftTriggersSnapshot()
}
}
})
savedFlow = {
...(newFlow || savedFlow?.draft_only
? {
...structuredClone($state.snapshot(flowStore.val)),
path: $pathStore,
draft_only: true
}
: savedFlow),
draft: {
...structuredClone($state.snapshot(flowStore.val)),
path: $pathStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
} as FlowWithDraftAndDraftTriggers
let savedAtNewPath = false
if (newFlow) {
onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' })
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
savedAtNewPath = true
initialPath = $pathStore
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' })
// this is so we can use the flow builder outside of sveltekit
}
onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow })
sendUserToast('Saved as draft')
} catch (error) {
sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true)
onSaveDraftError?.({ error })
}
loadingDraft = false
}
onMount(() => {
setSavedraftCb?.(() => saveDraft())
})
// No-op: persistence happens via the page-level UserDraft autosave.
export function saveDraft(): void {}
export function computeUnlockedSteps(flow: Flow) {
return Object.fromEntries(
@@ -677,7 +548,7 @@
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? [])
...(untrack(() => draftTriggersFromUrl) ?? [])
],
untrack(() => selectedTriggerIndexFromUrl)
)
@@ -706,10 +577,6 @@
$primaryScheduleStore,
$userStore
)
if (savedFlow && savedFlow.draft) {
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
}
}
function handleUndo() {
@@ -834,7 +701,6 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
draft: savedFlow?.draft,
current: {
...currentFlow,
path: $pathStore,
@@ -879,25 +745,7 @@
const mod = isMac() ? '⌘' : 'Ctrl+'
function getMoreItems(): Item[] {
// When the top bar is compact, fold the inline Diff + Save draft buttons
// in here so they stay reachable. Save draft keeps its keyboard shortcut.
const compactExtras: Item[] = compactTopbar
? [
...(customUi?.topBar?.draft !== false
? [
{
displayName: 'Save draft',
icon: Save,
action: () => saveDraft(),
shortcut: `${mod}S`,
disabled: (!newFlow && !savedFlow) || loading
}
]
: [])
]
: []
return [
...compactExtras,
...baseMenuItems,
{
displayName: 'Undo',
@@ -905,7 +753,7 @@
action: () => handleUndo(),
disabled: $history.index === 0,
shortcut: `${mod}Z`,
separatorTop: compactExtras.length > 0 || baseMenuItems.length > 0
separatorTop: baseMenuItems.length > 0
},
{
displayName: 'Redo',
@@ -1018,17 +866,7 @@
]
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved flow to remove the draft trigger that is deployed
if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) {
const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedFlow.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
function handleDeployTrigger(_trigger: Trigger) {}
let forceTestTab: Record<string, boolean> = $state({})
let highlightArg: Record<string, string | undefined> = $state({})
@@ -1058,6 +896,29 @@
if (p) untrack(() => ($pathStore = p))
})
// Persist the user-typed path into the draft JSON as `draft_path`
// when it differs from the deployed/seeded `flow.path`. The Path
// widget binds `$pathStore` one-way to the popover input — without
// this, the friendly auto-name on `/flows/add` and any in-place
// rename never reach the autosaved Flow, so the home-list draft row
// kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop
// the field once it matches the baseline again so it doesn't
// linger after a revert; deploy clears the whole draft, so the
// field naturally disappears post-deploy too.
$effect(() => {
const typed = $pathStore
const baseline = (flowStore.val as Flow | undefined)?.path ?? ''
const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined
if (!flow) return
untrack(() => {
if (typed && typed !== baseline) {
flow.draft_path = typed
} else if (flow.draft_path !== undefined) {
delete (flow as any).draft_path
}
})
})
$effect.pre(() => {
selectedId && untrack(() => select(selectedId))
})
@@ -1181,7 +1042,7 @@
bind:clientWidth={topbarWidth}
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
>
<div class="min-w-[200px] max-w-full">
<div class="flex flex-row items-center gap-2 min-w-[200px] max-w-full">
<EditorHeader
bind:summary={flowStore.val.summary}
bind:path={$pathStore}
@@ -1189,6 +1050,15 @@
onBehalfOfEmail={$savedOnBehalfOfEmail}
onNavigate={(item) => onNavigate?.(item)}
/>
{#if $workspaceStore && liveEditorDraftStoragePath !== undefined}
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="flow"
path={liveEditorDraftStoragePath}
draftOnly={newFlow}
{onResetToDeployed}
/>
{/if}
</div>
<div class="flex flex-row gap-2 items-center">
{#if $enterpriseLicense && !newFlow}
@@ -1207,9 +1077,11 @@
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedFlow}
disabled={!savedFlow || newFlow}
iconOnly={compactTopbar}
title="Diff"
title={newFlow
? 'Deploy this flow once to compare against the deployed version'
: 'Diff'}
startIcon={{ icon: DiffIcon }}
>
Diff
@@ -1218,19 +1090,7 @@
{#if !compactTopbar}
{@render previewButtons()}
{/if}
{#if customUi?.topBar?.draft !== false && !compactTopbar}
<Button
loading={loadingDraft}
unifiedSize="md"
variant="accent"
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
disabled={(!newFlow && !savedFlow) || loading}
shortCut={{ key: 'S' }}
>
Draft
</Button>
{/if}
<DeployButton
on:save={async ({ detail }) => await handleSaveFlow(detail)}
@@ -250,18 +250,32 @@
if (ws in states) return
untrack(() => {
Promise.all([
ResourceService.getResource({ workspace: ws, path: initialPath }),
ResourceService.getResource({ workspace: ws, path: initialPath, getDraft: true }),
getUserExt(ws)
]).then(([r, user]) => {
// `r` is the deployed `Resource` (wire shape `{path, value,
// description, labels, ws_specific, ...}`); the autosaved
// draft (if any) sits in `.draft` as the editor's internal
// `ResourceState` shape — the editor reads it directly.
const savedDraftState = (r as any).draft as ResourceState | undefined
fetchedResources[ws] = r
fetchedRev[ws] = r.edited_at
const s: ResourceState = {
// The deployed baseline, translated into the editor's
// `ResourceState` shape. Kept as the dirty-check reference
// so the "unsaved changes" banner compares draft-vs-deployed
// instead of loaded-vs-current — when a saved draft exists,
// the form opens with `draft != deployed` so the banner
// fires immediately, exactly as if the user had typed.
const deployedState: ResourceState = {
path: r.path,
description: r.description ?? '',
args: (r.value ?? {}) as any,
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
}
// What the editor opens with: the saved draft if present,
// otherwise the deployed.
const s: ResourceState = savedDraftState ?? deployedState
// Reconcile the local autosave with the backend before the
// handle is registered. If the backend moved on since the
// autosave was written (recorded rev != current rev) surface
@@ -291,7 +305,7 @@
}
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
initialStates[ws] = structuredClone(deployedState)
existedInitially[ws] = true
perWsUser[ws] = user
// Keep resource_type in sync for the base workspace (controls the schema)
+98 -214
View File
@@ -3,10 +3,9 @@
const bubble = createBubbler()
import {
DraftService,
ScriptService,
type NewScriptWithDraft,
type Script,
type NewScript,
type TriggersCount,
PostgresTriggerService,
CaptureService,
@@ -31,7 +30,6 @@
workspaceStore
} from '$lib/stores'
import {
cleanValueProperties,
emptySchema,
emptyString,
generateRandomString,
@@ -59,14 +57,13 @@
EllipsisVertical,
Plus,
Rocket,
Save,
Settings,
Shuffle,
Tag,
X
} from 'lucide-svelte'
import DropdownV2 from './DropdownV2.svelte'
import { isMac, type Item } from '$lib/utils'
import { type Item } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import Awareness from './Awareness.svelte'
@@ -84,6 +81,7 @@
import DefaultScripts from './DefaultScripts.svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import EditorHeader from './EditorHeader.svelte'
import AutosaveIndicator from './AutosaveIndicator.svelte'
import LabelsInput from './LabelsInput.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
@@ -92,13 +90,7 @@
import CaptureTable from './triggers/CaptureTable.svelte'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import {
type NewScriptWithDraftAndDraftTriggers,
type Trigger,
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import type { ScriptBuilderProps } from './script_builder'
@@ -109,11 +101,13 @@
import { buildForkEditUrl } from '$lib/utils/editInFork'
import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte'
import WacExportDrawer from './scripts/WacExportDrawer.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
let {
script = $bindable(),
fullyLoaded = true,
initialPath = $bindable(''),
userDraftPath = '',
template = $bindable('script'),
initialArgs = {},
lockedLanguage = false,
@@ -129,14 +123,12 @@
children,
onDeploy,
onDeployError,
onSaveInitial,
onSeeDetails,
onSaveDraftError,
onSaveDraft,
onNavigate,
disableAi,
initialTestPanelCollapsed = false,
initialPathChosen = false
initialPathChosen = false,
onResetToDeployed
}: ScriptBuilderProps = $props()
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
@@ -165,18 +157,10 @@
// Top-bar responsive collapse — container width, not viewport.
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
const mod = isMac() ? '⌘' : 'Ctrl+'
function getCompactMenuItems(): Item[] {
const hasTags = ($workerTags?.length ?? 0) > 0
return [
{
displayName: 'Save draft',
icon: Save,
action: () => saveDraft(),
shortcut: `${mod}S`,
disabled: initialPath != '' && !savedScript
},
...(customUi?.topBar?.tagEdit != false && hasTags
? [
{
@@ -291,13 +275,6 @@
$primaryScheduleStore,
$userStore
)
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
savedScript = filterDraftTriggers(
savedScript,
triggersState
) as NewScriptWithDraftAndDraftTriggers
}
}
// Add triggers context store
@@ -368,9 +345,16 @@
let pathError = $state('')
let loadingSave = $state(false)
let loadingDraft = $state(false)
if (script.content == '') {
// Suspend autosave around the bootstrap mutations — seeding the
// editor with the template's `initialCode` is a programmatic
// write that shouldn't count as the user's "first edit" and
// shouldn't POST to the server. The route's UserDraft handle is
// keyed by `userDraftPath` (the URL path), distinct from
// `initialPath` which is the editor-displayed path (empty for
// new drafts).
UserDraft.stopSync('script', userDraftPath)
if (template === 'wac_python') {
script.modules = {
'helper.py': {
@@ -386,7 +370,47 @@
}
}
}
initContent(script.language, script.kind, template)
// Two cascades have to settle before sync resumes: the async
// `initContent` filling `script.content` from the template, AND
// the Path widget's `$workspaceStore && $userStore`-gated
// `initPath → reset → onMetaChange → bind:path` chain that
// auto-generates a friendly path for new drafts. Whichever lands
// last calls `tryRestart`; we await two ticks past it so the
// `bind:path` cascade itself observably settles before sync
// re-arms — without that gap, the auto-generated path is the
// first observable change post-restart and POSTs as a "user
// edit".
let initContentDone = false
let storesReady = !!($userStore && $workspaceStore)
let restarted = false
async function tryRestart() {
if (restarted || !initContentDone || !storesReady) return
// 500ms past initContent + stores-ready: the Path widget's
// `$workspaceStore && $userStore`-gated cascade (`initPath →
// await tick → reset → onMetaChange → bind:path`) lands well
// inside this window even on cold reload. Two `tick()`s were
// not enough in practice — the bind:path mutation fired ~100ms
// after `restartSync` and posted as a "user edit".
await new Promise((r) => setTimeout(r, 500))
if (restarted) return
restarted = true
UserDraft.restartSync('script', userDraftPath)
}
initContent(script.language, script.kind, template).finally(() => {
initContentDone = true
void tryRestart()
})
// Cold-reload path: the auth stores load over the network, so
// `storesReady` may flip from false → true after mount. The
// effect cleans itself up via the `restarted` guard.
if (!storesReady) {
$effect(() => {
if ($userStore && $workspaceStore) {
storesReady = true
untrack(() => void tryRestart())
}
})
}
}
async function isTemplateScript() {
@@ -426,8 +450,16 @@
| 'ci_test_python'
) {
scriptEditor?.disableCollaboration()
// Seed the template content SYNCHRONOUSLY so a user clicking
// Deploy before the (async) template-script fetch resolves
// doesn't run `inferArgs` on an empty `script.content` and toast
// "Could not parse code". If a template script is then loaded
// below we re-seed with the `templateScript=true` variant.
script.content = initialCode(language, kind, template, false)
const templateScript = await isTemplateScript()
script.content = initialCode(language, kind, template, templateScript != undefined)
if (templateScript) {
script.content = initialCode(language, kind, template, true)
}
if (templateScript) {
script.content += '\r\n' + templateScript
}
@@ -532,7 +564,16 @@
loadingSave = true
try {
script.schema = script.schema ?? emptySchema()
// `?? emptySchema()` only catches null/undefined — a legacy draft
// (seeded with `schema: {}` before the new-draft route was
// fixed) lands here with an object missing `properties`. That
// trips `inferArgs` at `JSON.parse(JSON.stringify(schema.properties))`
// (stringify of undefined → undefined, parse → "undefined is
// not valid JSON"), which surfaces as the "Could not parse
// code" toast even on perfectly valid bun template content.
if (!script.schema || !(script.schema as any).properties) {
script.schema = emptySchema()
}
try {
const result = await inferArgs(
script.language,
@@ -622,7 +663,7 @@
}
const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script))
savedScript = structuredClone($state.snapshot(newScript)) as NewScriptWithDraft
savedScript = structuredClone($state.snapshot(newScript))
setDraftTriggers([])
if (!disableHistoryChange) {
@@ -652,154 +693,8 @@
loadingSave = false
}
async function saveDraft(forceSave = false): Promise<void> {
scriptEditor?.flushModuleState()
if (initialPath != '' && !savedScript) {
return
}
if (savedScript) {
const draftOrDeployed = cleanValueProperties(savedScript.draft || savedScript)
const currentTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties({ ...script, draft_triggers: currentTriggers })
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraft(true)
}
}
])
return
}
}
loadingDraft = true
try {
script.schema = script.schema ?? emptySchema()
try {
const result = await inferArgs(
script.language,
script.content,
script.schema as any,
script.kind === 'preprocessor' ? 'preprocessor' : undefined
)
if (script.kind === 'preprocessor') {
script.auto_kind = undefined
script.has_preprocessor = undefined
} else {
script.auto_kind = result?.auto_kind || undefined
script.has_preprocessor = result?.has_preprocessor || undefined
}
} catch (error) {
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
let newHash = ''
if (initialPath == '' || savedScript?.draft_only) {
if (savedScript?.draft_only) {
await ScriptService.deleteScriptByPath({
workspace: $workspaceStore!,
path: initialPath,
keepCaptures: true
})
script.parent_hash = undefined
}
if (!initialPath || script.path != initialPath) {
await CaptureService.moveCapturesAndConfigs({
workspace: $workspaceStore!,
path: initialPath || fakeInitialPath,
requestBody: {
new_path: script.path
},
runnableKind: 'script'
})
}
newHash = await ScriptService.createScript({
workspace: $workspaceStore!,
requestBody: {
path: script.path,
summary: script.summary,
description: script.description ?? '',
content: script.content,
schema: script.schema,
is_template: script.is_template,
language: script.language,
kind: script.kind,
tag: script.tag,
draft_only: true,
envs: script.envs,
concurrent_limit: script.concurrent_limit,
concurrency_time_window_s: script.concurrency_time_window_s,
debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key,
debounce_delay_s: script.debounce_delay_s,
debounce_args_to_accumulate:
script.debounce_args_to_accumulate && script.debounce_args_to_accumulate.length > 0
? script.debounce_args_to_accumulate
: undefined,
max_total_debouncing_time: script.max_total_debouncing_time,
max_total_debounces_amount: script.max_total_debounces_amount,
cache_ttl: script.cache_ttl,
cache_ignore_s3_path: script.cache_ignore_s3_path,
ws_error_handler_muted: script.ws_error_handler_muted,
priority: script.priority,
restart_unless_cancelled: script.restart_unless_cancelled,
timeout: script.timeout,
concurrency_key: emptyString(script.concurrency_key)
? undefined
: script.concurrency_key,
visible_to_runner_only: script.visible_to_runner_only,
auto_kind: script.auto_kind,
has_preprocessor: script.has_preprocessor,
on_behalf_of_email: script.on_behalf_of_email,
assets: script.assets,
modules: script.modules,
labels: script.labels
}
})
}
const draftTriggers = triggersState.getDraftTriggersSnapshot()
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
typ: 'script',
value: {
...script,
draft_triggers: draftTriggers
}
}
})
const clonedScript = structuredClone($state.snapshot(script))
savedScript = {
...(initialPath == '' || savedScript?.draft_only
? { ...clonedScript, draft_only: true }
: savedScript),
draft: {
...clonedScript,
draft_triggers: draftTriggers
}
} as NewScriptWithDraftAndDraftTriggers
let savedAtNewPath = false
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
savedAtNewPath = true
initialPath = script.path
onSaveInitial?.({ path: script.path, hash: newHash })
}
onSaveDraft?.({ path: script.path, savedAtNewPath, script })
sendUserToast('Saved as draft')
} catch (error) {
sendUserToast(
`Error while saving the script as a draft: ${error.body || error.message}`,
true
)
onSaveDraftError?.({ path: script.path, error })
}
loadingDraft = false
}
// No-op: persistence happens via the page-level UserDraft autosave.
function saveDraft(): void {}
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
@@ -829,10 +724,7 @@
})
}
function computeDropdownItems(
initialPath: string,
savedScript: NewScriptWithDraftAndDraftTriggers | undefined
) {
function computeDropdownItems(initialPath: string, savedScript: Script | NewScript | undefined) {
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
? [
@@ -993,17 +885,7 @@
}
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved script to remove the draft trigger that is deployed
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
const newSavedDraftTrigers = savedScript.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedScript.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
function handleDeployTrigger(_trigger: Trigger) {}
function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) {
if (lang == 'docker') {
@@ -1984,6 +1866,15 @@
onNavigate={(item) => onNavigate?.(item)}
/>
{/if}
{#if $workspaceStore}
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="script"
path={userDraftPath}
draftOnly={(savedScript as any)?.no_deployed === true}
{onResetToDeployed}
/>
{/if}
</div>
<!-- Separator -->
@@ -2014,13 +1905,16 @@
{/snippet}
{#snippet diffButton()}
{#if customUi?.topBar?.diff != false}
{@const isDraftOnly = (savedScript as any)?.no_deployed === true}
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedScript || !diffDrawer}
disabled={!savedScript || !diffDrawer || isDraftOnly}
iconOnly={compactTopbar}
title="Diff"
title={isDraftOnly
? 'Deploy this script once to compare against the deployed version'
: 'Diff'}
startIcon={{ icon: DiffIcon }}
>
Diff
@@ -2058,19 +1952,9 @@
{/if}
{/if}
{@render settingsButton()}
<Button
loading={loadingDraft}
unifiedSize="md"
variant="accent"
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
disabled={initialPath != '' && !savedScript}
shortCut={{ key: 'S' }}
>
<span> Draft </span>
</Button>
{/if}
<DeployButton
loading={!fullyLoaded}
{loadingSave}
@@ -2106,8 +1990,8 @@
autoKind={script.auto_kind}
{template}
tag={script.tag}
lastSavedCode={savedScript?.draft?.content}
lastDeployedCode={savedScript?.draft_only ? undefined : savedScript?.content}
lastSavedCode={savedScript?.content}
lastDeployedCode={savedScript?.content}
bind:args
bind:hasPreprocessor
bind:captureTable
@@ -159,11 +159,26 @@
if (ws in states) return
untrack(() => {
Promise.all([
VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }),
VariableService.getVariable({
workspace: ws,
path: p,
decryptSecret: false,
getDraft: true
}),
getUserExt(ws)
]).then(([v, user]) => {
// `v` is the deployed `Variable` (wire shape); the autosaved
// draft (if any) sits in `.draft` as the editor's internal
// `VariableState` shape — the editor reads it directly.
const savedDraftState = (v as any).draft as VariableState | undefined
fetchedRev[ws] = v.edited_at
const s: VariableState = {
// The deployed baseline, translated into the editor's
// `VariableState` shape. Kept as the dirty-check reference
// so the "unsaved changes" banner compares draft-vs-deployed
// instead of loaded-vs-current — when a saved draft exists,
// the form opens with `draft != deployed` so the banner
// fires immediately, exactly as if the user had typed.
const deployedState: VariableState = {
path: v.path,
variable: {
value: v.value ?? '',
@@ -173,6 +188,9 @@
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
}
// What the editor opens with: the saved draft if present,
// otherwise the deployed.
const s: VariableState = savedDraftState ?? deployedState
// See ResourceEditor for the same pattern: a backend that
// moved on since the autosave was written → staleness modal;
// otherwise just a "showing your local autosave" toast with
@@ -191,7 +209,7 @@
}
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
initialStates[ws] = structuredClone(deployedState)
existedInitially[ws] = true
extraPerms[ws] = v.extra_perms ?? {}
perWsUser[ws] = user
@@ -32,8 +32,6 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
type Kind = WorkspaceItemKind
type Item = WorkspaceItem
@@ -155,32 +153,6 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
}
}
// Chat tools and session editor previews write drafts through
// `UserDraft` (workspace-scoped, localStorage-backed). Merge those into
// the picker so users can navigate to in-flight items that haven't been
// deployed yet. Filter to kinds the picker actually displays.
//
// Gated on the same dev flag as the rest of the sessions feature: without
// it there are no sessions, so the only UserDrafts present are the
// standalone editors' autosaves — surfacing those in the breadcrumb picker
// would be surprising (they'd appear as navigable items that 404 on the
// backend draft fetch). When the flag is off this is a no-op.
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
function aiDraftsForKind(k: Kind): Item[] {
if (!isGlobalAiEnabled()) return []
if (!$workspaceStore) return []
const targetType = KIND_TO_DRAFT_TYPE[k]
return listGlobalDrafts($workspaceStore)
.filter((d) => d.type === targetType)
.map((d) => ({
path: d.path,
summary: d.summary ?? '',
kind: k,
// `raw_app` lives on the draft envelope for legacy/raw-app distinction.
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
}))
}
// Searching is global → load every kind.
$effect(() => {
if (filter.trim() !== '') for (const k of kinds) ensureLoaded(k)
@@ -194,17 +166,6 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
leaves: Item[]
}
/** Merge AI-created in-memory drafts into a kind's list. The AI may have
* scaffolded a script/flow/app via chat tools without the user saving
* yet — those drafts should be navigable from the picker. Existing items
* (same path) win to keep the backend's metadata (summary etc.). */
function withAiDrafts(items: Item[], k: Kind): Item[] {
const ai = aiDraftsForKind(k)
if (ai.length === 0) return items
const known = new Set(items.map((it) => it.path))
return items.concat(ai.filter((d) => !known.has(d.path)))
}
/** Inject the currently-edited item into a kind's list at its live path,
* dropping the saved entry when a draft rename is in progress. Other kinds
* pass through untouched. */
@@ -272,7 +233,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* cached. */
function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] {
if (!kinds.includes(k)) return []
const items = withAiDrafts(withCurrent(list ?? [], k), k)
const items = withCurrent(list ?? [], k)
if (items.length === 0) return []
return buildTreeFromItems(items)
}
@@ -284,7 +245,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
* one folder hierarchy. Each leaf still carries its real kind, so the row
* icon and `editPathFor` routing still work; folders contain a mix. */
const allTree = $derived.by(() => {
const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k))
const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k))
return merged.length === 0 ? [] : buildTreeFromItems(merged)
})
@@ -320,7 +281,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level
let allItems = $derived<SearchInput[]>(
kinds.flatMap((k) =>
withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({
withCurrent(loaded[k] ?? [], k).map((it) => ({
...it,
_key: `${k}:${it.path}`
}))
@@ -3,7 +3,7 @@
const bubble = createBubbler()
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { getContext, onMount, setContext, untrack } from 'svelte'
import { getContext, onMount, setContext, tick, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { Pane, Splitpanes } from 'svelte-splitpanes'
@@ -77,10 +77,10 @@
newPath = undefined,
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
unsavedConfirmationModal,
onSavedNewAppPath,
onNavigate,
initialRevs
initialRevs,
onResetToDeployed
}: AppEditorProps = $props()
migrateApp(untrack(() => app))
@@ -93,14 +93,29 @@
// sides' autosaves. Skip UserDraft entirely in that case.
const inSessionPane = !!getContext('aiChatManager')
const appDraftPath = newApp ? '' : (path ?? '')
// `path` is the URL path (e.g. `u/{user}/draft_{uuid}` after the
// `/apps/add` redirect, or a deployed app's path on `/apps/edit/...`).
// The autosave keys on it directly so a refresh of
// `/apps/edit/u/{user}/draft_{uuid}` finds the user's saved draft at
// the same path, and the listing's draft-only branch (which scans the
// `draft` table by exact path) picks it up.
const appDraftPath = path ?? ''
const appDraftHandle = inSessionPane ? undefined : 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.
// Suspend autosave around mount — the route may have seeded `app`
// from an empty template (e.g. `/apps/add` redirect with
// `new_draft=true`), and the `firstMirror` effect below writes that
// seed into the handle as a programmatic mutation. Without
// suspension that write fires a POST that looks like the user's
// first edit before they've touched anything. `onMount`-then-`tick`
// resumes once all mount-time effects have settled, so the user's
// real first edit is the first POST.
if (appDraftHandle) UserDraft.stopSync('app', appDraftPath)
// Prefer the persisted autosave over the prop when both exist (the
// route always initializes `app` to an empty template on
// `new_draft=true`, but a prior session at this draft path may have
// left an autosave). The route is responsible for wiping the entry
// (`UserDraft.remove`) when it wants to force a fresh start
// (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
@@ -482,6 +497,14 @@
let mounted = false
onMount(() => {
mounted = true
// Resume autosave now that mount-time effects (the
// `firstMirror` mirror, prop-driven initialization, ...) have
// all run. `tick` waits for the current pending effect flush
// to complete so the post-suspend writes have been observed by
// the sync effect and silently dropped.
if (appDraftHandle) {
tick().then(() => UserDraft.restartSync('app', appDraftPath))
}
setTimeout(() => {
if ($initialized?.initialized === false) {
@@ -858,8 +881,6 @@
;[!!$connectingInput.opened, !$panzoomActive]
untrack(() => updateCursorStyle(!!$connectingInput.opened && !$panzoomActive))
})
const unsavedConfirmationModal_render = $derived(unsavedConfirmationModal)
</script>
<svelte:head></svelte:head>
@@ -883,6 +904,8 @@
<AppEditorHeader
{newPath}
{newApp}
userDraftPath={appDraftPath}
{onResetToDeployed}
on:restore
{policy}
{fromHub}
@@ -901,19 +924,7 @@
onHideLeftPanel={() => hideLeftPanel()}
onHideRightPanel={() => hideRightPanel()}
onHideBottomPanel={() => hideBottomPanel()}
>
{#snippet unsavedConfirmationModal({
diffDrawer,
additionalExitAction,
getInitialAndModifiedValues
})}
{@render unsavedConfirmationModal_render?.({
diffDrawer,
additionalExitAction,
getInitialAndModifiedValues
})}
{/snippet}
</AppEditorHeader>
/>
{#if $mode === 'preview'}
<SplitPanesWrapper class="border-t">
<div
@@ -3,7 +3,7 @@
import Button from '$lib/components/common/button/Button.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { AppService, type Policy } from '$lib/gen'
import { redo, undo } from '$lib/history.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { enterpriseLicense, tutorialsToDo, userStore, workspaceStore } from '$lib/stores'
@@ -39,12 +39,7 @@
Globe
} from 'lucide-svelte'
import { getContext, untrack } from 'svelte'
import {
cleanValueProperties,
orderedJsonStringify,
type Value,
replaceFalseWithUndefined
} from '../../../utils'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../../utils'
import type { App, AppEditorContext, AppViewerContext } from '../types'
import { toStatic } from '../utils'
import AppExportButton from './AppExportButton.svelte'
@@ -64,9 +59,10 @@
import DebugPanel from './contextPanel/DebugPanel.svelte'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { goto } from '$app/navigation'
import { beforeNavigate, goto } from '$app/navigation'
import HideButton from './settingsPanel/HideButton.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
@@ -74,7 +70,6 @@
import LazyModePanel from './contextPanel/LazyModePanel.svelte'
import type { DiffDrawerI } from '$lib/components/diff_drawer'
import AppEditorHeaderDeploy from './AppEditorHeaderDeploy.svelte'
import AppEditorHeaderDeployInitialDraft from './AppEditorHeaderDeployInitialDraft.svelte'
import { computeSecretUrl } from './appDeploy.svelte'
import { updatePolicy } from './appPolicy'
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
@@ -88,11 +83,9 @@
savedApp?:
| {
value: App
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
@@ -102,7 +95,9 @@
bottomPanelHidden?: boolean
newApp: boolean
newPath?: string
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
/** URL path under which the per-user draft is keyed. Empty string for
* the `/apps/add` route since no draft is persisted there yet. */
userDraftPath?: string
onSavedNewAppPath?: (path: string) => void
onShowRightPanel?: () => void
onShowLeftPanel?: () => void
@@ -111,6 +106,9 @@
onHideLeftPanel?: () => void
onHideBottomPanel?: () => void
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
// Threaded to the `AutosaveIndicator` popover so its "Reset to
// deployed" button can do the same thing the load-time toast offers.
onResetToDeployed?: () => void | Promise<void>
}
let {
@@ -124,7 +122,7 @@
bottomPanelHidden = false,
newApp,
newPath = '',
unsavedConfirmationModal,
userDraftPath = '',
onSavedNewAppPath,
onShowLeftPanel,
onShowRightPanel,
@@ -132,7 +130,8 @@
onHideLeftPanel,
onHideRightPanel,
onHideBottomPanel,
onNavigate = undefined
onNavigate = undefined,
onResetToDeployed
}: Props = $props()
/** Mirror of the path the user is editing in the pen popover. Initialized
@@ -140,13 +139,15 @@
* updated by user input from then on — we deliberately do NOT sync from
* `newPath` afterwards so the user's in-flight rename isn't clobbered by
* a parent reload that re-supplies the saved path. The fallback chain at
* read sites (`newEditedPath || savedApp?.draft?.path || savedApp?.path`)
* read sites (`newEditedPath || savedApp?.path`)
* handles the case where `newEditedPath` is briefly empty before the
* synthesized initialization runs — falls through to the saved path so
* rename detection still works. */
let newEditedPath = $state(
untrack(() =>
newApp ? userPathPrefix($userStore?.username) + random_adj() + '_app' : (newPath ?? '')
newApp && !newPath
? userPathPrefix($userStore?.username) + random_adj() + '_app'
: (newPath ?? '')
)
)
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
@@ -169,6 +170,29 @@
darkMode
} = getContext<AppViewerContext>('AppViewerContext')
// Persist the user-typed path into the bare App draft as
// `draft_path` when it differs from the deployed/seeded baseline.
// The Path widget binds `newEditedPath` (popover-local), and the
// App's own shape has no `path` field — without this, the friendly
// auto-name on `/apps/add` and any in-place rename never reach the
// autosaved value, so the home-list draft row kept showing the
// autogenerated `u/{user}/draft_{uuid}` slot. Drop it once it
// matches the baseline again; deploy clears the whole draft, so
// the field naturally disappears post-deploy.
$effect(() => {
const typed = newEditedPath
const baseline = savedApp?.path ?? ''
const a = $app as (typeof $app & { draft_path?: string }) | undefined
if (!a) return
untrack(() => {
if (typed && typed !== baseline) {
a.draft_path = typed
} else if (a.draft_path !== undefined) {
delete (a as any).draft_path
}
})
})
const { history, jobsDrawerOpen, refreshComponents } =
getContext<AppEditorContext>('AppEditorContext')
@@ -182,8 +206,7 @@
const loading = $state({
publish: false,
save: false,
saveDraft: false
save: false
})
let selectedJobId: string | undefined = $state(undefined)
@@ -191,7 +214,6 @@
let pathError: string = $state('')
let appExport: AppExportButton | undefined = $state()
let draftDrawerOpen = $state(false)
let saveDrawerOpen = $state(false)
let inputsDrawerOpen = $state(untrack(() => fromHub))
let historyBrowserDrawerOpen = $state(false)
@@ -208,10 +230,6 @@
saveDrawerOpen = false
}
function closeDraftDrawer() {
draftDrawerOpen = false
}
async function createApp(path: string) {
policy = await updatePolicy($app, policy)
try {
@@ -267,7 +285,7 @@
replaceFalseWithUndefined({
summary: $summary,
value: $app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
})
@@ -367,156 +385,6 @@
return
}
async function saveInitialDraft() {
policy = await updatePolicy($app, policy)
try {
await AppService.createApp({
workspace: $workspaceStore!,
requestBody: {
value: $app,
path: newEditedPath,
summary: $summary,
policy,
draft_only: true,
custom_path: customPath
}
})
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: newEditedPath,
typ: 'app',
value: {
value: $app,
path: newEditedPath,
summary: $summary,
policy,
custom_path: customPath
}
}
})
savedApp = {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: newEditedPath,
policy,
draft_only: true,
draft: {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: newEditedPath,
policy,
custom_path: customPath
},
custom_path: customPath
}
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.
if (!inSessionPane) UserDraft.remove('app', $appPath)
onSavedNewAppPath?.(newEditedPath)
} catch (e) {
sendUserToast('Error saving initial draft', e)
}
draftDrawerOpen = false
}
async function saveDraft(forceSave = false) {
if (newApp) {
// initial draft
draftDrawerOpen = true
return
}
if (!savedApp) {
return
}
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
const current = cleanValueProperties({
summary: $summary,
value: $app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
policy
})
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraft(true)
}
}
])
return
}
loading.saveDraft = true
try {
policy = await updatePolicy($app, policy)
let path = $appPath
if (savedApp.draft_only) {
await AppService.deleteApp({
workspace: $workspaceStore!,
path: path
})
await AppService.createApp({
workspace: $workspaceStore!,
requestBody: {
value: $app!,
summary: $summary,
policy,
path: newEditedPath || path,
draft_only: true,
custom_path: customPath
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: savedApp.draft_only ? newEditedPath || path : path,
typ: 'app',
value: {
value: $app!,
summary: $summary,
policy,
path: newEditedPath || path
}
}
})
savedApp = {
...(savedApp?.draft_only
? {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: savedApp.draft_only ? newEditedPath || path : path,
policy,
draft_only: true,
custom_path: customPath
}
: savedApp),
draft: {
summary: $summary,
value: structuredClone($state.snapshot($app)),
path: newEditedPath || path,
policy,
custom_path: customPath
}
}
sendUserToast('Draft saved')
if (!inSessionPane) UserDraft.remove('app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
onSavedNewAppPath?.(newEditedPath || path)
}
} catch (e) {
loading.saveDraft = false
throw e
}
}
let onLatest = $state(true)
async function compareVersions() {
if (version === undefined) {
@@ -562,7 +430,6 @@
break
case 's':
if (event.ctrlKey || event.metaKey) {
saveDraft()
event.preventDefault()
}
break
@@ -606,13 +473,6 @@
let moreItems = $derived([
...(compactTopbar
? [
{
displayName: 'Save draft',
icon: Save,
action: () => saveDraft(),
shortcut: `${mod}S`,
disabled: !newApp && !savedApp
},
{
displayName: `Debug runs (${$jobs?.length > 99 ? '99+' : ($jobs?.length ?? 0)})`,
icon: Bug,
@@ -689,13 +549,13 @@
action: () => {
appReportingDrawerOpen = true
},
disabled: !savedApp || savedApp.draft_only
disabled: !savedApp
},
{
displayName: 'Diff',
icon: DiffIcon,
action: async () => {
if (!savedApp) {
if (!savedApp || newApp) {
return
}
@@ -706,17 +566,16 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
}
})
},
disabled: !savedApp
disabled: !savedApp || newApp
},
// App debug menu
{
@@ -804,6 +663,12 @@
let priorDarkMode = document.documentElement.classList.contains('dark')
setTheme($app?.darkMode)
// Restore the user's prior theme on navigation away from the editor; the
// app's darkMode override would otherwise leak into the next page.
beforeNavigate(() => {
setTheme(priorDarkMode)
})
let customPath = $state(savedApp?.custom_path)
$effect(() => {
@@ -823,24 +688,6 @@
<svelte:window onkeydown={onKeyDown} />
{#if unsavedConfirmationModal}
{@render unsavedConfirmationModal?.({
diffDrawer,
additionalExitAction: () => {
setTheme(priorDarkMode)
},
getInitialAndModifiedValues: () => ({
savedValue: savedApp,
modifiedValue: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
policy,
custom_path: customPath
}
})
})}
{/if}
<DeployOverrideConfirmationModal
{deployedBy}
{confirmCallback}
@@ -850,39 +697,12 @@
currentValue={{
summary: $summary,
value: $app,
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
path: newEditedPath || savedApp?.path,
policy,
custom_path: customPath
}}
/>
{#if $appPath == ''}
<Drawer bind:open={draftDrawerOpen} size="800px">
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
{#snippet actions()}
<div>
<Button
startIcon={{ icon: Save }}
disabled={pathError != ''}
on:click={() => saveInitialDraft()}
unifiedSize="md"
variant="accent"
>
Save initial draft
</Button>
</div>
{/snippet}
<AppEditorHeaderDeployInitialDraft
bind:summary={$summary}
bind:appPath={$appPath}
bind:pathError
bind:newEditedPath
/>
</DrawerContent>
</Drawer>
{/if}
<AppJobsDrawer
bind:open={$jobsDrawerOpen}
jobs={$jobs}
@@ -905,9 +725,9 @@
<div class="flex flex-row gap-4">
<Button
variant="accent"
disabled={!savedApp || savedApp.draft_only}
disabled={!savedApp || newApp}
on:click={async () => {
if (!savedApp) {
if (!savedApp || newApp) {
return
}
// deployedValue should be syncronized when we open Diff
@@ -918,18 +738,17 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: $summary,
value: $app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
},
button: {
text: 'Looks good, deploy',
onClick: () => {
if ($appPath == '') {
if (newApp) {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
@@ -949,7 +768,12 @@
startIcon={{ icon: Save }}
disabled={pathError != '' || customPathError != ''}
on:click={() => {
if ($appPath == '') {
// `newApp=true` for /apps/add → /edit/draft_{uuid}
// and for draft-only paths (no deployed row).
// `$appPath` is the URL path the user landed on
// (an autogenerated `draft_{uuid}` for new apps),
// so it's a poor signal for "should we create?".
if (newApp) {
createApp(newEditedPath)
} else {
handleUpdateApp(newEditedPath)
@@ -962,6 +786,7 @@
{/snippet}
<AppEditorHeaderDeploy
{newPath}
{newApp}
{policy}
{setPublishState}
appPath={$appPath}
@@ -1117,6 +942,17 @@
</ToggleButtonGroup>
</div>
</div>
{#if $workspaceStore}
<div class="ml-4">
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="app"
path={userDraftPath}
draftOnly={newApp}
{onResetToDeployed}
/>
</div>
{/if}
</div>
{#if $mode !== 'preview'}
@@ -1210,19 +1046,6 @@
</div>
<AppExportButton bind:this={appExport} />
<PreviewToggle loading={loading.save} />
{#if !compactTopbar}
<Button
variant="accent"
loading={loading.save}
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
unifiedSize="md"
disabled={!newApp && !savedApp}
shortCut={{ key: 'S' }}
>
Draft
</Button>
{/if}
<Button
variant="accent"
loading={loading.save}
@@ -36,7 +36,8 @@
newPath,
hideSecretUrl = false,
preserveOnBehalfOf = $bindable(false),
rawApp = false
rawApp = false,
newApp = false
}: {
policy: any
setPublishState: () => void
@@ -57,6 +58,11 @@
// document and break no-CORP cross-origin subresources (external images,
// {@html} embeds, CDN imports).
rawApp?: boolean
/** True while the editor is on a draft-only URL (`/edit/u/{user}/draft_{uuid}`
* with no deployed row yet). Suppresses the public-secret-URL fetch
* (`/secret_of/...` 404s with no `app` row) and renders a placeholder
* instead of the eternally-spinning link. */
newApp?: boolean
} = $props()
let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false)
@@ -139,7 +145,15 @@
})
$effect(() => {
appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl())
// Skip the secret URL fetch on draft-only items — `/secret_of/...`
// has no `app` row to look up and would 404, leaving the UI
// component spinning indefinitely.
!newApp &&
appPath &&
appPath != '' &&
savedApp &&
secretUrl == undefined &&
untrack(() => getSecretUrl())
})
</script>
@@ -264,11 +278,11 @@
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
setPublishState()
}}
disabled={!savedApp}
disabled={!savedApp || newApp}
/>
</div>
{#if !savedApp}
<ClipboardPanel content={`Save this app once to get the public secret URL`} size="md" />
{#if !savedApp || newApp}
<ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" />
{:else if secretUrlHref}
<div class="flex justify-end mb-1">
<Toggle
@@ -1,55 +0,0 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import Path from '$lib/components/Path.svelte'
let {
summary = $bindable(),
appPath = $bindable(),
pathError = $bindable(),
newEditedPath = $bindable()
} = $props()
let path: Path | undefined = $state(undefined)
let dirtyPath = $state(false)
</script>
<Alert bgClass="mb-4" title="Require path" type="info">
Choose a path to save the initial draft of the app.
</Alert>
<h3>Summary</h3>
<div class="w-full pt-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
type="text"
placeholder="App summary"
class="text-sm w-full font-semibold"
onkeydown={(e) => {
e.stopPropagation()
}}
bind:value={summary}
onkeyup={() => {
if (appPath == '' && summary?.length > 0 && !dirtyPath) {
path?.setName(
summary
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/-+/g, '_')
.replace(/^-|-$/g, '')
)
}
}}
/>
</div>
<div class="py-2"></div>
<Path
autofocus={false}
bind:this={path}
bind:error={pathError}
bind:path={newEditedPath}
bind:dirty={dirtyPath}
initialPath=""
namePlaceholder="app"
kind="app"
/>
<div class="py-4"></div>
@@ -26,7 +26,7 @@
targetTutorial = undefined
}}
on:confirmed={async () => {
window.open(`/apps/add?tutorial=${targetTutorial}&nodraft=true`, '_blank')
window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank')
}}
>
<div class="flex flex-col w-full space-y-4">
@@ -1,11 +1,12 @@
<script lang="ts">
import { Badge, Button } from '$lib/components/common'
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import JsonEditor from '../../JsonEditor.svelte'
import { AppService, DraftService } from '$lib/gen'
import { AppService } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { sendUserToast } from '$lib/toast'
import { userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
@@ -15,63 +16,83 @@
let code: string = $state('')
let path: string = ''
let useDraft: boolean = $state(false)
let loading = $state(true)
let isDraftOnly = $state(false)
let isRawApp = $state(false)
const dispatch = createEventDispatcher()
let app: any | undefined = undefined
export async function open(path_l: string) {
/**
* Open the JSON drawer for an item from the home list. `rawApp` MUST
* be set when the row is a raw-app draft, since `get_draft=true` has
* no deployed row to read the kind from — without it the backend
* looks for an `app` draft, doesn't find one, and 404s.
*/
export async function open(path_l: string, rawApp = false) {
loading = true
jsonViewerDrawer?.toggleDrawer()
path = path_l
const fapp = await AppService.getAppByPathWithDraft({
// `get_draft=true` so draft-only items at `u/{user}/draft_{uuid}`
// resolve to the synthesized draft stand-in instead of 404'ing the
// home-page "View/Edit JSON" menu entry. Deployed apps continue to
// return the deployed payload unchanged (see WithDraftOverlay).
const fapp = (await AppService.getAppByPath({
workspace: $workspaceStore!,
path
})
useDraft = fapp?.draft != undefined
path,
getDraft: true,
rawApp
})) as any
app = { ...fapp }
if (fapp.draft) {
delete app['draft']
}
const capp = fapp?.draft ? fapp.draft : fapp.value
code = JSON.stringify(capp, null, 4)
isDraftOnly = !!fapp.no_deployed
isRawApp = !!fapp.raw_app || rawApp
// Draft-only items: the editor's autosave writes the bare editable
// shape (low-code: App; raw-app: `{files, runnables, data, ...}`)
// straight into `draft`, so render that. The flattened `inner` has
// the same content but is polluted with overlay fields
// (`is_draft`, `no_deployed`, …) we don't want in the JSON.
// Deployed items: the editable shape is the App definition under
// `.value` (raw-app deploys keep the same response wrapper).
const display = isDraftOnly ? fapp.draft : fapp.value
code = JSON.stringify(display, null, 4)
loading = false
}
export async function saveApp() {
await AppService.updateApp({
workspace: $workspaceStore!,
path,
requestBody: { ...app, value: JSON.parse(code) }
})
dispatch('change')
UserDraft.remove('app', path)
sendUserToast('App deployed')
}
export async function saveDraft() {
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: path,
typ: 'app',
value: JSON.parse(code)
}
})
dispatch('change')
UserDraft.remove('app', path)
sendUserToast('Draft saved')
const parsed = JSON.parse(code)
if (isDraftOnly) {
// Draft-only items have no deployed row — `updateApp` would
// 404. Route the edit through the syncer (`immediate: true`
// so the caller's `await` resolves only after the POST lands)
// so the user keeps editing under the draft path until they
// rename + deploy from the regular editor. `parsed` is already
// the bare editable shape (App or raw-app value) — match what
// the autosave writes so the regular editor reads it back
// unchanged on next mount.
await UserDraftDbSyncer.save({
workspace: $workspaceStore!,
itemKind: isRawApp ? 'raw_app' : 'app',
path,
value: parsed,
immediate: true
})
dispatch('change')
sendUserToast('Draft saved')
} else {
await AppService.updateApp({
workspace: $workspaceStore!,
path,
requestBody: { ...app, value: parsed }
})
dispatch('change')
UserDraft.remove('app', path)
sendUserToast('App deployed')
}
}
</script>
<Drawer bind:this={jsonViewerDrawer} size="800px">
<DrawerContent title="App JSON" on:close={() => jsonViewerDrawer?.toggleDrawer()}>
{#if useDraft}
<div class="mb-1">
<Badge small color="indigo" baseClass="border border-indigo-200">+Draft</Badge>
</div>
{/if}
{#if loading}
<Loader2 class="animate-spin" />
{:else}
@@ -80,11 +101,13 @@
{#snippet actions()}
{#if !$userStore?.operator}
<Button on:click={saveDraft} startIcon={{ icon: Save }} variant="accent" size="xs">
Save as draft
</Button>
<Button on:click={saveApp} startIcon={{ icon: Globe }} variant="accent" size="xs">
Deploy
<Button
on:click={saveApp}
startIcon={{ icon: isDraftOnly ? Save : Globe }}
variant="accent"
size="xs"
>
{isDraftOnly ? 'Save draft' : 'Deploy'}
</Button>
{/if}
{/snippet}
+4 -3
View File
@@ -149,11 +149,9 @@ export interface AppEditorProps {
savedApp?:
| {
value: App
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
@@ -162,7 +160,6 @@ export interface AppEditorProps {
newPath?: string | undefined
replaceStateFn?: (path: string) => void
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
onSavedNewAppPath?: (path: string) => void
/** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
@@ -176,6 +173,10 @@ export interface AppEditorProps {
* drift `previousMeta` would be empty and the modal wouldn't fire.
*/
initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta
// Threaded through `AppEditorHeader` to the `AutosaveIndicator`
// popover so its "Reset to deployed" button can do the same thing
// the load-time toast offers.
onResetToDeployed?: () => void | Promise<void>
}
export type App = {
@@ -0,0 +1,92 @@
<script lang="ts">
/**
* Surfaces the conflict snapshot left by `UserDraftDbSyncer.postSave`
* when the server rejects a save because the row's `created_at` has
* advanced past our `last_sync` (another tab/browser/user pushed an
* intervening write). The route mounts one of these per editor: it
* reads the reactive conflict handle and offers two resolutions —
* pull the remote (discards local edits) or push over it.
*/
import { UserDraftDbSyncer, type UserDraftLastSyncQuery } from '$lib/userDraftDbSyncer.svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { AlertTriangle } from 'lucide-svelte'
type Props = {
query: UserDraftLastSyncQuery
/** Editor-side reload — re-fetches the deployed-overlay response,
* resets in-memory state, and (implicitly via the loader calling
* `recordRemoteSync`) updates the local `last_sync` to the
* server's clock. The modal awaits this before closing. */
onLoadFromServer: () => Promise<void> | void
/** Current local draft value to overwrite the server with. The
* modal passes it through `UserDraftDbSyncer.overwrite`. */
getLocalDraft: () => unknown
}
let { query, onLoadFromServer, getLocalDraft }: Props = $props()
const conflictHandle = $derived(UserDraftDbSyncer.getConflict(query))
let isOpen = $derived(conflictHandle.conflict !== undefined)
let busy = $state(false)
async function loadFromServer() {
busy = true
try {
await onLoadFromServer()
UserDraftDbSyncer.clearConflict(query)
} finally {
busy = false
}
}
async function overwriteServer() {
busy = true
try {
await UserDraftDbSyncer.overwrite({
workspace: query.workspace,
itemKind: query.itemKind,
path: query.path,
value: getLocalDraft()
})
} finally {
busy = false
}
}
</script>
<Modal2 bind:isOpen title="Draft out of sync" fixedWidth="sm" fixedHeight="xs">
<div class="flex flex-col w-full gap-4">
<div class="flex gap-3 items-start flex-1">
<AlertTriangle size={20} class="text-yellow-500 shrink-0 mt-0.5" />
<div class="text-sm text-primary flex flex-col gap-1">
<p>
Another tab, browser, or AI agent saved a newer version of this draft. Your autosave was
rejected to avoid overwriting their work.
</p>
{#if conflictHandle.conflict}
<p class="text-xs text-secondary">
Server timestamp: {new Date(conflictHandle.conflict.serverTimestamp).toLocaleString()}
</p>
{/if}
</div>
</div>
<div class="flex justify-end gap-2">
<Button
variant="default"
size="sm"
disabled={busy}
on:click={() => UserDraftDbSyncer.clearConflict(query)}
>
Dismiss
</Button>
<Button variant="default" size="sm" disabled={busy} on:click={overwriteServer}>
Overwrite the remote
</Button>
<Button variant="accent" size="sm" loading={busy} on:click={loadFromServer}>
Load from server
</Button>
</div>
</div>
</Modal2>
@@ -0,0 +1,209 @@
<script lang="ts">
/**
* Banner-style modal shown on editor mount when the deployed-overlay
* response carries `other_drafts_users` — i.e. someone other than the
* authed user (or the legacy NULL-email row) also has a saved draft at
* this path.
*
* The list of owners is part of the get-by-path payload (so we don't
* fan out a second request just to populate the banner); individual
* drafts are fetched on-demand for the "View JSON" / "Fork" actions so
* the deploy-overlay response stays lean when many users are working
* on the same item.
*/
import { DraftService, type UserDraftItemKind } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { goto } from '$lib/navigation'
import { Users, GitFork, Braces } from 'lucide-svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
export type OtherDraftUser = { username?: string | null }
type Props = {
workspace: string
itemKind: UserDraftItemKind
path: string
/** Workspace username of the authed user — used to namespace the
* fork path (`u/{currentUserUsername}/...`). */
currentUserUsername: string | undefined
/** Owners list from the deployed-overlay response. Each entry has a
* workspace `username` (or `null` for the legacy workspace-level
* row). The authed user is already filtered out server-side. */
otherDraftsUsers: OtherDraftUser[]
/** Route hook: build the per-editor edit URL for a forked draft path.
* Different editors live under different roots (`/scripts/edit/`,
* `/flows/edit/`, ...) so the route owns the URL shape. */
editPathFor: (forkedPath: string) => string
}
let { workspace, itemKind, path, currentUserUsername, otherDraftsUsers, editPathFor }: Props =
$props()
let isOpen = $state(otherDraftsUsers.length > 0)
let busyFor = $state<string | null>(null)
let jsonOpen = $state(false)
let jsonOwnerLabel = $state('')
let jsonValue = $state<unknown>(undefined)
function ownerLabel(owner: OtherDraftUser): string {
return owner.username ?? 'Legacy draft'
}
function ownerKey(owner: OtherDraftUser): string {
return owner.username ?? '__legacy__'
}
/** Derive the fork target path. `u/{currentUser}/{leaf}_{owner}_fork`
* where leaf = the last segment of the source path. For the legacy
* row we use `_legacy_fork` instead of an owner username. */
function forkPath(owner: OtherDraftUser): string {
const leaf = path.split('/').pop() ?? path
const ownerSuffix = owner.username ?? 'legacy'
return `u/${currentUserUsername ?? 'me'}/${leaf}_${ownerSuffix}_fork`
}
async function fetchDraft(owner: OtherDraftUser): Promise<unknown> {
return (
await DraftService.getDraftForUser({
workspace,
kind: itemKind,
path,
username: owner.username ?? undefined
})
).value
}
async function viewJson(owner: OtherDraftUser) {
busyFor = ownerKey(owner)
try {
jsonValue = await fetchDraft(owner)
jsonOwnerLabel = ownerLabel(owner)
jsonOpen = true
} catch (e) {
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
} finally {
busyFor = null
}
}
async function fork(owner: OtherDraftUser) {
busyFor = ownerKey(owner)
try {
const value = await fetchDraft(owner)
const target = forkPath(owner)
// Bypass the autosave debouncer so the fork lands on the
// server BEFORE we navigate. The destination route loads
// via `getDraft=true` and 404s if no draft yet exists at
// the fork path — `UserDraft.save` alone would have
// scheduled a debounced POST 1.5s out, so a fresh nav was
// always too early.
await UserDraftDbSyncer.save({
workspace,
itemKind,
path: target,
value,
immediate: true
})
// Close the banner BEFORE the navigation so the user sees the
// modal disappear on click. Without this the modal stays
// visible during the navigation tear-down — Svelte hasn't
// torn down the previous route's components by the time
// `goto` returns, so the banner lingers on top of the
// destination editor for a beat.
isOpen = false
goto(editPathFor(target))
} catch (e) {
sendUserToast(`Could not fork draft: ${e.body ?? e.message}`, true)
} finally {
busyFor = null
}
}
</script>
<Modal2
bind:isOpen
title="Other users are currently working on {path}"
fixedWidth="sm"
fixedHeight="sm"
closeOnOutsideClick={!jsonOpen}
>
<div class="flex flex-col w-full gap-4">
<div class="flex gap-3 items-start">
<Users size={20} class="text-blue-500 shrink-0 mt-0.5" />
<p class="text-sm text-secondary">
Their drafts are independent of yours. Open one as JSON to inspect it, or fork it into your
own namespace to continue editing.
</p>
</div>
<ul class="divide-y border-t border-b flex-1 overflow-y-auto">
{#each otherDraftsUsers as owner (ownerKey(owner))}
<li class="flex items-center gap-3 py-2">
<div class="flex-1 min-w-0 flex items-center gap-2">
<span class="text-sm font-medium text-primary truncate" class:italic={!owner.username}>
{ownerLabel(owner)}
</span>
{#if !owner.username}
<Tooltip>
Pre-migration workspace-scoped draft (no owner). Saved before drafts became per-user
— kept around so you can recover the content, but no current user owns it.
</Tooltip>
{/if}
</div>
<Button
variant="default"
size="xs"
startIcon={{ icon: Braces }}
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
loading={busyFor === ownerKey(owner)}
on:click={() => viewJson(owner)}
>
View JSON
</Button>
<Button
variant="default"
size="xs"
startIcon={{ icon: GitFork }}
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
loading={busyFor === ownerKey(owner)}
on:click={() => fork(owner)}
>
Fork
</Button>
</li>
{/each}
</ul>
<div class="flex justify-end">
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>Continue anyway</Button>
</div>
</div>
</Modal2>
<Modal2
bind:isOpen={jsonOpen}
title="Draft JSON — {jsonOwnerLabel}"
fixedWidth="lg"
fixedHeight="lg"
>
{#snippet headerRight()}
<Button
variant="default"
size="xs"
on:click={() => {
navigator.clipboard?.writeText(JSON.stringify(jsonValue, null, 2))
sendUserToast('Copied to clipboard')
}}
>
Copy
</Button>
{/snippet}
<div class="w-full overflow-auto">
<pre class="text-xs whitespace-pre font-mono bg-surface-secondary rounded p-3"
>{JSON.stringify(jsonValue ?? {}, null, 2)}</pre
>
</div>
</Modal2>
@@ -2,8 +2,6 @@
import ConfirmationModal from './ConfirmationModal.svelte'
import { beforeNavigate } from '$app/navigation'
import { goto as gotoUrl } from '$app/navigation'
import Button from '../button/Button.svelte'
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
import {
cleanValueProperties,
orderedJsonStringify,
@@ -16,7 +14,6 @@
interface Props {
getInitialAndModifiedValues?: GetInitialAndModifiedValues
diffDrawer?: DiffDrawer | undefined
additionalExitAction?: () => void
triggerOnSearchParamsChange?: boolean
onDiscardChanges?: () => void
@@ -25,7 +22,6 @@
let {
getInitialAndModifiedValues = undefined,
diffDrawer = undefined,
additionalExitAction = () => {},
triggerOnSearchParamsChange = false,
onDiscardChanges = undefined,
@@ -111,37 +107,5 @@
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to discard the changes you have made? </span>
{#if savedValue && modifiedValue && diffDrawer}
<Button
wrapperClasses="self-start"
variant="default"
size="xs"
on:click={() => {
if (!savedValue || !modifiedValue) {
return
}
open = false
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: savedValue,
draft: savedValue.draft,
current: modifiedValue,
defaultDiffType: 'draft',
button: {
text: 'Leave anyway',
onClick: () => {
if (goingTo) {
bypassBeforeNavigate = true
additionalExitAction?.()
gotoUrl(goingTo)
}
}
}
})
}}
>Show diff
</Button>
{/if}
</div>
</ConfirmationModal>
@@ -17,6 +17,11 @@
fixedWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
fixedHeight?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
contentClasses?: string
/** Close when the user clicks outside the modal body. Default
* true. Set false when the caller stacks a child modal on top
* and clicks "outside" the child would otherwise propagate
* here and close the underlying modal. */
closeOnOutsideClick?: boolean
headerLeft?: import('svelte').Snippet
headerRight?: import('svelte').Snippet
children?: import('svelte').Snippet
@@ -25,11 +30,15 @@
let {
title,
css = {},
target = '',
// Forwarded to `Portal`. An empty string would hit
// `document.querySelector('')` and throw "The provided selector
// is empty" — match `Portal`'s own default instead.
target = 'body',
isOpen = $bindable(false),
fixedWidth = 'md',
fixedHeight = 'md',
contentClasses = '',
closeOnOutsideClick = true,
headerLeft,
headerRight,
children
@@ -61,6 +70,7 @@
}
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen) return
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
@@ -91,7 +101,9 @@
css?.popup?.class,
'wm-modal-form-popup'
)}
use:clickOutside={{ onClickOutside: () => close() }}
use:clickOutside={{
onClickOutside: () => closeOnOutsideClick && close()
}}
>
<List gap="md">
<div class="flex w-full">
@@ -3,13 +3,14 @@
import Dropdown from '$lib/components/DropdownV2.svelte'
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { AppService, DraftService, type ListableApp } from '$lib/gen'
import { AppService, type ListableApp } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import Badge from '../badge/Badge.svelte'
import {
ExternalLink,
@@ -28,7 +29,7 @@
import { goto as gotoUrl } from '$app/navigation'
import { page } from '$app/state'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { DELETE, copyToClipboard } from '$lib/utils'
import { copyToClipboard } from '$lib/utils'
import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte'
import { isDeployable } from '$lib/utils_deployable'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
@@ -37,7 +38,7 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
app: ListableApp & { draft_only?: boolean; canWrite: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -64,11 +65,31 @@
const dispatch = createEventDispatcher()
let appExport: { open: (path: string) => void } | undefined = $state(undefined)
let appExport: { open: (path: string, rawApp?: boolean) => void } | undefined = $state(undefined)
let appDeploymentHistory: AppDeploymentHistory | undefined = $state(undefined)
async function loadAppJson() {
appExport?.open(app.path)
// Thread the row's `raw_app` flag so the JSON drawer's backend
// fetch picks the right draft kind on draft-only items (no
// deployed row to read the kind from server-side).
appExport?.open(app.path, !!app.raw_app)
}
async function deleteApp(path: string): Promise<void> {
// Draft-only items have no deployed row — the regular route would
// 404. Route the delete through the syncer instead; the `app` vs
// `raw_app` choice mirrors the row's own `raw_app` flag.
if (app.draft_only) {
await UserDraftDbSyncer.save({
workspace: $workspaceStore ?? '',
itemKind: app.raw_app ? 'raw_app' : 'app',
path,
value: null,
immediate: true
})
} else {
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
}
}
</script>
@@ -83,7 +104,7 @@
href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}"
kind="app"
{marked}
path={app.path}
path={(app as any).draft_path ?? app.path}
summary={app.summary}
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
canFavorite={!app.draft_only}
@@ -98,7 +119,7 @@
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
<DraftBadge has_draft={app.has_draft} draft_only={app.draft_only} />
<DraftBadge is_draft={app.is_draft} draft_only={app.draft_only} />
{#if app.labels?.length}
<div class="flex items-center gap-0.5">
{#each app.labels.slice(0, 3) as label}
@@ -130,7 +151,7 @@
variant="subtle"
wrapperClasses="w-20"
startIcon={{ icon: Pen }}
href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}?nodraft=true"
href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}"
>
Edit
</Button>
@@ -155,7 +176,7 @@
aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`}
aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`}
items={async () => {
let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app
let { draft_only, canWrite, summary, execution_mode, path } = app
const canEdit = canWrite && showEditButton
if (draft_only) {
@@ -167,11 +188,11 @@
// TODO
// @ts-ignore
if (event?.shiftKey) {
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
await deleteApp(path)
dispatch('change')
} else {
deleteConfirmedCallback = async () => {
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
await deleteApp(path)
dispatch('change')
}
}
@@ -271,25 +292,6 @@
}
]
: []),
...(has_draft
? [
{
displayName: 'Delete Draft',
icon: Trash,
action: async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore ?? '',
path,
kind: 'app'
})
dispatch('change')
},
type: DELETE,
disabled: !canWrite,
hide: $userStore?.operator
}
]
: []),
{
displayName: 'Delete',
icon: Trash,
@@ -297,11 +299,11 @@
// TODO
// @ts-ignore
if (event?.shiftKey) {
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
await deleteApp(path)
dispatch('change')
} else {
deleteConfirmedCallback = async () => {
await AppService.deleteApp({ workspace: $workspaceStore ?? '', path })
await deleteApp(path)
dispatch('change')
}
}
@@ -5,16 +5,17 @@
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow, DraftService } from '$lib/gen'
import { FlowService, type Flow } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import { sendUserToast } from '$lib/toast'
import { DELETE, copyToClipboard, isOwner } from '$lib/utils'
import { copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
@@ -39,7 +40,12 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean }
flow: Flow & {
draft_only?: boolean
is_draft?: boolean
draft_path?: string
canWrite: boolean
}
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -84,7 +90,20 @@
async function deleteFlow(path: string): Promise<void> {
try {
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
// Draft-only items have no deployed row to delete — the regular
// route would 404. Route the delete through the syncer so the
// per-user draft row is removed instead.
if (flow.draft_only) {
await UserDraftDbSyncer.save({
workspace: $workspaceStore!,
itemKind: 'flow',
path,
value: null,
immediate: true
})
} else {
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
}
dispatch('change')
sendUserToast(`Deleted flow ${path}`)
} catch (err) {
@@ -104,12 +123,12 @@
aiId={`flow-row-${flow.path}`}
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
href={flow.draft_only
? `${base}/flows/edit/${flow.path}?nodraft=true`
? `${base}/flows/edit/${flow.path}`
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
kind="flow"
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
{marked}
path={flow.path}
path={flow.draft_path ?? flow.path}
summary={flow.summary}
{errorHandlerMuted}
canFavorite={!flow.draft_only}
@@ -121,7 +140,7 @@
<Badge color="red" baseClass="border">archived</Badge>
{/if}
<SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} />
<DraftBadge has_draft={flow.has_draft} draft_only={flow.draft_only} />
<DraftBadge is_draft={flow.is_draft} draft_only={flow.draft_only} />
{#if flow.labels?.length}
<div class="flex items-center gap-0.5">
{#each flow.labels.slice(0, 3) as label}
@@ -152,7 +171,7 @@
wrapperClasses="w-20"
unifiedSize="md"
startIcon={{ icon: Pen }}
href="{base}/flows/edit/{flow.path}?nodraft=true"
href="{base}/flows/edit/{flow.path}"
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
>
@@ -180,7 +199,7 @@
aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`}
items={async () => {
let { draft_only, path, archived, has_draft } = flow
let { draft_only, path, archived } = flow
let owner = isOwner(path, $userStore, $workspaceStore)
const canEdit = flow.canWrite && showEditButton
if (draft_only) {
@@ -293,25 +312,6 @@
disabled: !owner || !canEdit,
hide: $userStore?.operator
},
...(has_draft
? [
{
displayName: 'Delete Draft',
icon: Trash,
action: async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore ?? '',
path,
kind: 'flow'
})
dispatch('change')
},
type: DELETE,
disabled: !owner,
hide: $userStore?.operator
}
]
: []),
{
displayName: 'Delete',
icon: Trash,
@@ -5,18 +5,19 @@
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { ScriptService, type Script, DraftService } from '$lib/gen'
import { ScriptService, type Script } from '$lib/gen'
import { hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import { sendUserToast } from '$lib/toast'
import { capitalize, copyToClipboard, DELETE, isOwner } from '$lib/utils'
import { capitalize, copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
@@ -51,7 +52,12 @@
import { isCloudHosted } from '$lib/cloud'
interface Props {
script: Script & { canWrite: boolean; use_codebase: boolean }
script: Script & {
canWrite: boolean
use_codebase: boolean
is_draft?: boolean
draft_path?: string
}
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
@@ -103,7 +109,20 @@
}
async function deleteScript(path: string): Promise<void> {
await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path })
// Draft-only items have no deployed row to delete — the regular
// route would 404. Route the delete through the syncer so the
// per-user draft row is removed instead.
if (script.draft_only) {
await UserDraftDbSyncer.save({
workspace: $workspaceStore!,
itemKind: 'script',
path,
value: null,
immediate: true
})
} else {
await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path })
}
dispatch('change')
sendUserToast(`Deleted script ${path}`)
}
@@ -126,7 +145,7 @@
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
kind="script"
{marked}
path={script.path}
path={script.draft_path ?? script.path}
summary={script.summary}
{errorHandlerMuted}
workspaceId={$workspaceStore ?? ''}
@@ -168,7 +187,7 @@
>
{/if}
<SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} />
<DraftBadge has_draft={script.has_draft} draft_only={script.draft_only} />
<DraftBadge is_draft={script.is_draft} draft_only={script.draft_only} />
{#if script.labels?.length}
<div class="flex items-center gap-0.5">
{#each script.labels.slice(0, 3) as label}
@@ -404,25 +423,6 @@
hide: $userStore?.operator
},
...(script.has_draft
? [
{
displayName: 'Delete Draft',
icon: Trash,
action: async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore ?? '',
path: script.path,
kind: 'script'
})
dispatch('change')
},
type: DELETE,
disabled: !owner,
hide: $userStore?.operator
}
]
: []),
...($userStore?.is_admin || $userStore?.is_super_admin
? [
{
@@ -0,0 +1,322 @@
# Migrating the AI chat global mode to DB-backed drafts (PR #9351)
Working notes for moving the global chat mode off localStorage-backed drafts and onto
the DB-backed user-draft layer introduced in
[PR #9351 "Db-backed user drafts"](https://github.com/windmill-labs/windmill/pull/9351).
> Status: the PR work is checked out locally (branch reset to the PR head + latest
> `main` merged in). This doc tracks the global-mode-specific follow-up that the PR
> itself does **not** complete.
---
## 1. How global-mode drafts work today
The global mode never touches `localStorage` directly. It persists drafts through the
shared `UserDraft` API:
```
core.ts tools
→ userDraftAdapter.ts (getGlobalDraft / listGlobalDrafts / saveGlobalAppDraft / deleteGlobalDraft …)
→ UserDraft (frontend/src/lib/userDraft.svelte.ts)
→ localStorage ← on main
```
- `userDraftAdapter.ts` maps between the global mode's `WorkspaceItem` shape and the
generic `UserDraft` entries (`UserDraftItemKind` = `script | flow | raw_app |
trigger_* | resource | variable`).
- Reads go through `UserDraft.get()` / `UserDraft.list()`; writes through
`UserDraft.save()` / `setDraftAndMeta()` / `remove()` / `clear()`.
- On `main`, `UserDraft` persists to `localStorage`, so `get`/`list` see every draft
the user ever wrote in this browser, regardless of whether an editor is mounted.
## 2. What PR #9351 changes
**Backend** (`backend/windmill-api/src/drafts.rs`, `backend/windmill-common/src/user_drafts.rs`,
migration `20260528143710_draft_user_sync_schema`):
- Reshapes the existing `draft` table into **per-user** storage: adds an `email`
column (NULL = legacy workspace-level draft), swaps the `DRAFT_TYPE` enum
(`script/flow/app`) for `DRAFT_KIND` (every `UserDraftItemKind`), and replaces the
PK with two partial unique indexes so per-user and legacy rows coexist.
- New endpoints:
- `POST /w/{ws}/drafts/save_draft/{kind}/{path}` — upsert (or delete on `null`
value) with optimistic concurrency (`last_sync` / `force`).
- `GET /w/{ws}/drafts/get_draft/{kind}/{path}` — fetch **my** draft value.
- `GET /w/{ws}/drafts/list_drafts`**metadata-only** list of my drafts
(`{ path, typ, saved_at }`), most-recent first. **No value, no summary.**
- `GET /w/{ws}/drafts/get/{kind}/{path}?username=…` — another user's draft (for the
"other users' drafts" modal).
**Frontend** (`userDraft.svelte.ts` + `userDraftDbSyncer.svelte.ts`):
- Every `UserDraft` mutation now funnels into `UserDraftDbSyncer.save(...)` — a
write-through layer with a debouncer + coalescing runner, optimistic concurrency,
conflict surfacing, and a `keepalive` unload flush.
- **The localStorage *persistence* layer is removed.** `UserDraft.get()`/`list()` now
only see **in-tab mounted handles** (the in-memory `entries` map). The comment on
`UserDraft.list()` is explicit: *"for a workspace-wide view across sessions, call
`DraftService.listDrafts` instead."*
**Global mode in the PR:** `userDraftAdapter.ts` is **untouched**; the only `core.ts`
changes drop the *old* server per-item draft reads (`getScriptByPathWithDraft`
`getScriptByPath`, removing `remoteDraftRev`/`draft_created_at`).
## 3. The gap: why the global mode still needs work
- **Writes** ride along for free — `UserDraft.save(...)` already syncs to the DB.
- **Reads break.** The global mode is headless: it never mounts a live handle
(`UserDraft.use`), it just does save-then-read. Under the PR:
- `UserDraft.save()` for a path with **no mounted handle** pushes to the syncer but
**does not populate `entries`** (`userDraft.svelte.ts` `save`).
- `UserDraft.get()`/`list()` then return `undefined`/`[]`.
- So `saveGlobalAppDraft` (adapter) and `getRequiredGlobalDraft` (`core.ts`) — which
save then immediately read — would throw *"Could not read written draft"*, and
`listGlobalDrafts` would miss anything from another tab/device.
There are ~15 read call-sites in `core.ts` going through
`getGlobalDraft` / `listGlobalDrafts` / `getGlobalDraftStoragePath`.
## 4. Migration strategy (shared principles)
The fix is to point the adapter's **read paths** at the DB while writes keep flowing
through the synced `UserDraft` layer:
1. **Reads → `DraftService`.** `getGlobalDraft``getDraft`; `listGlobalDrafts`
`listDrafts`. These become **async**, so `await` has to be threaded through the
`core.ts` call-sites (most are already inside async tool handlers).
2. **Writes → awaited DB save when read back immediately.** For the save-then-read
pattern, use an awaited DB write (`UserDraftDbSyncer.save({ …, immediate: true })`
/ `overwrite`, or `DraftService.saveDraft`) before reading. AI-driven overwrites
pass `force: true` (no human at a conflict modal).
3. **Deletes**`save_draft` with `value: null`. `clearGlobalDrafts` must enumerate
via `listDrafts` (it can no longer rely on `UserDraft.list`).
4. **`getLiveEditorDraft` stays in-memory** — it's the open editor's path/rename
mapping, unrelated to persistence.
### Gotchas
- **Secrets are safe.** Secret variable drafts already store `value: ''` and keep the
real secret only in the in-memory `secretVariableDraftValues` map
(`userDraftAdapter.ts` / `core.ts` `syncEphemeralSecretVariableDraftValue`). Keep
that map **out** of the DB sync — DB-backing the draft value won't leak secrets.
- **Item-kind parity.** Every global kind (`raw_app`, `trigger_*`, `resource`,
`variable`) already exists in the `DRAFT_KIND` DB enum and the generated
`UserDraftItemKind`. Note `app` (low-code editor drafts) vs `raw_app` (global mode's
app kind): `GLOBAL_DRAFT_KINDS` excludes plain `app`, so the global list must filter
to `GLOBAL_DRAFT_KINDS`.
- **UI copy.** `core.ts` toasts say "Saving … to local storage" — reword to reflect DB
persistence.
- **Tests.** `core.test.ts` mocks the storage layer; expect to swap mocks to
`DraftService` per tool.
- After backend API changes: regenerate sqlx (`cargo sqlx prepare` / `update-sqlx`
skill), update `openapi.yaml`, then `npm run generate-backend-client`.
## 5. Per-tool migration plan (checklist)
Work one tool at a time; each is independently shippable behind the existing
`wm_dev_global_ai` gate.
| # | Tool / helper | What changes |
|---|---------------|--------------|
| 1 | `list_workspace_items` | **✅ Done** — reuse list endpoints' `includeDraftOnly` + `isDraft` flag for script/flow/app; `listGlobalDrafts` removed (see §6) |
| 2 | `read_workspace_item` (`getGlobalDraft`) | **✅ Done** — read → `readGlobalDraftValue` (in-memory → `DraftService.getDraft`); `getGlobalDraft` now async (see §7) |
| 3 | `write_script` / `write_flow` / `write_app` | **✅ Done** — existing-draft read + read-back via the seam; save via `saveGlobalDraftValue` (live → `UserDraft`, headless → awaited forced DB save) |
| 4 | `write` trigger / schedule | **✅ Done** — same seam as #3 for trigger kinds |
| 5 | `write_resource` / `write_variable` | **✅ Done** — same seam as #3; secret ephemeral map untouched (DB value stays `''`) |
| 6 | `delete_draft` (`deleteGlobalDraft`) | **✅ Done** — async; always awaits an immediate forced `save_draft` null (DB row gone before return), and additionally clears the in-tab cell when an editor is mounted so a read-back can't resurrect it |
| 7 | `clearGlobalDrafts` | **✅ Done** — enumerate via `DraftService.listDrafts` (filtered to `GLOBAL_DRAFT_KINDS`), delete each |
| 8 | deploy path | **✅ Done** — reads the DB draft via async `getGlobalDraft` before deploying; post-deploy delete is awaited |
## 6. Tool #1`list_workspace_items` (implemented)
**Decision (revised):** *reuse the existing list endpoints* instead of a separate draft
fetch. Post #9351, `ScriptService.listScripts` / `FlowService.listFlows` /
`AppService.listApps` already accept `includeDraftOnly: true` and join the `draft` table
**scoped to the authed user** (`draft.email = authed.email`), returning two flags on each
row:
- `draft_only` — the item exists only as a draft (never deployed)
- `is_draft` — a deployed item that has a pending draft for this user
(draft-only rows also set `is_draft = true`). `listWorkspaceItems` *already* called the
script/flow endpoints with `includeDraftOnly: true` — it just hardcoded `isDraft: false`.
So the core fix is to read the flags: no new endpoint, no `DraftService.listDrafts`, no
`typ`→type mapper. Two follow-ups from review were also needed: the **app** call was
missing `includeDraftOnly` (issue #1), and listing under a **`path_prefix`** needed a
small backend change so draft-only rows are filtered by prefix instead of dropped (issue
#2 / option (c) — see below).
### What was implemented
- **`core.ts` `listWorkspaceItems`** — all three list calls pass `includeDraftOnly: true`
(the app call previously omitted it — **issue #1 fix**), and `isDraft` is derived from
the returned flags:
- script/flow: `draft_only === true || is_draft === true` (both fields on the list row)
- app: `is_draft === true` (`ListableApp` exposes only `is_draft`; draft-only apps set it)
- **`core.ts` list tool** — dropped the `listGlobalDrafts` merge. Script/flow/app drafts
now arrive through `listWorkspaceItems`. Adds a small in-memory **live-editor merge**
(`listLiveEditorDrafts`, below) so the open editor's unsaved/renamed draft still shows
at its effective path with `isLiveDraft: true`, overriding the deployed entry (and
dropping the stale pre-rename key). A `TODO(db-drafts)` comment records that
schedule/trigger/resource/variable drafts are **not** discoverable here until their
list endpoints gain `includeDraftOnly` (still reachable by path via
`read_workspace_item`).
- **`userDraftAdapter.ts`** — removed `listGlobalDrafts` (and the short-lived
`draftKindToWorkspaceItem` helper); added `listLiveEditorDrafts(workspace)` which reads
the open editor's draft from the in-memory live registry (`getLiveEditorDraft`).
Value-less; existence/path are taken from the registry and **not** gated on the in-tab
value cell, so it's decoupled from the read-after-write gap (§3). `GLOBAL_DRAFT_KINDS`
stays (still used by `clearGlobalDrafts`, tool #7).
- **`WorkspaceItemDrillPicker.svelte`** — removed the `aiDrafts`/`withAiDrafts`
machinery; `loadKind` already uses `includeDraftOnly`, so draft-only script/flow/app
items surface naturally.
- **`global_drafts/+page.svelte`** (dev inspector) — now calls `DraftService.listDrafts`
directly (raw `{ path, typ, saved_at }`) and deletes via `DraftService.saveDraft`
(`value: null`). Self-contained; no dependency on the adapter.
- **Backend — `scripts.rs` / `flows.rs` / `apps.rs` `list_*`** (**issue #2, option (c)**):
the draft-only append no longer bails when `path_start` is set; instead the draft-only
query filters by it (`AND ($N::text IS NULL OR path LIKE $N || '%')`, mirroring the
deployed query's `and_where_like_left`). Other narrowing filters (`path_exact`,
`created_by`, `label`, languages, pages past 0) still skip the append. sqlx offline cache
regenerated for the three changed queries.
> **⚠️ Reviewer — please confirm (c):** removing the `path_start.is_none()` guard from the
> draft-only append is the chosen fix for issue #2. The guard was presumably there so
> pickers/selectors get a deployed-only listing; the global-chat list tool needs draft-only
> rows under a prefix, and honoring `path_start` in-query (rather than dropping draft-only)
> is the least-surprising behavior. If a caller relies on "prefix query ⇒ no synthesized
> draft-only rows", this changes that. Flagging for sign-off.
### Known limitation (intentional, awaiting backend)
`list_workspace_items` cannot surface schedule/trigger/resource/variable drafts — those
list endpoints have no per-user draft join yet. The list tool defaults to
`['script','flow']`, so the gap only bites when those kinds are explicitly requested.
Lift it by adding `includeDraftOnly` (email-scoped) to those endpoints.
### Verification (done)
- Backend confirmed live: a seeded draft-only script returns `draft_only: true,
is_draft: true` from `listScripts?include_draft_only=true`, scoped to the authed email.
After the (c) change, the same query **with** `path_start` set returns the draft-only row
when it matches the prefix (and omits it otherwise) — verified via the API.
- Unit (`core.test.ts`): `flags backend draft scripts and forwards path_prefix + limit` and
`requests draft-only apps and flags them via is_draft` (issue #1 guard) pass; the
live-editor **list** assertions in `lists … the live script/flow editor draft` pass too.
Those two live-editor tests then fail later in their *edit/write* half on the unrelated
read-after-write gap (`getXByPath mock not configured` — see §3/§8). No regressions.
- E2E (not yet run): with `wm_dev_global_ai` enabled, create a draft script via the global
mode, reload, confirm `list_workspace_items` flags it `isDraft: true`.
## 7. Tools #2#8 — the read/write seam (implemented)
The read-after-write gap (§3) is closed by a small async seam in
`userDraftAdapter.ts` that all global read/write/delete paths funnel through.
Writes still flow through the synced `UserDraft` layer **when an editor is
mounted**; otherwise they go straight to the DB and are awaited.
**`readGlobalDraftValue<V>(workspace, itemKind, storagePath)`** — raw draft
value. In-tab mounted cell wins (`UserDraft.get`, freshest — holds unsaved
live-editor edits); else `DraftService.getDraft` (404 → `undefined` via
`isNotFoundError`). Async. Replaces the writers' `UserDraft.get<…>` existing-draft
probe, so a draft written in a prior turn/tab is now found.
**`saveGlobalDraftValue<V>(workspace, itemKind, storagePath, value, meta?)`** —
if `UserDraft.isLive(...)` (a handle is mounted), route through
`UserDraft.save`/`setDraftAndMeta` so the open editor updates reactively (its
background syncer persists). Else push to `UserDraftDbSyncer.save({ immediate:
true, force: true })` and **await** it, so a read-back in the same turn sees the
value. Forced — AI overwrites have no human at a conflict modal. Rev metadata is
in-memory only and is dropped on the headless path (DB stores values, not revs;
the next editor mount reseeds it).
`UserDraft.isLive(itemKind, path, opts)` was added (`userDraft.svelte.ts`) — true
when an entry is mounted, regardless of whether it holds a value (distinct from
`has`).
**Wiring (`core.ts`):** `getGlobalDraft` / `getGlobalDraftSlot` / `saveGlobalAppDraft`
/ `deleteGlobalDraft` / `clearGlobalDrafts` are now async; `getRequiredGlobalDraft`
and the ~15 read/write/delete call-sites thread `await`. `clearGlobalDrafts`
enumerates `DraftService.listDrafts` (filtered to `GLOBAL_DRAFT_KINDS`).
Secret-variable handling is unchanged: the draft value stays `''` and the real
secret lives only in the in-memory `secretVariableDraftValues` map.
**Naming.** The drafts are no longer browser-local, so the "local storage" /
"local draft" copy across the model-facing surface (tool descriptions, system
prompt, confirmation messages, schema help, tool-result messages) was reworded
to "draft". The `discard_local_draft` tool was renamed to **`discard_draft`**
(internal `discardLocalDraft`/`Schema``discardDraft`/`Schema`); editor-view
comments referencing it were updated too. The unrelated `ResourceEditor`
`discardLocalDraft()` method is left alone. `discard_draft` is still a needed,
distinct operation (drop a draft, keep the deployed item) — not redundant with
`delete_workspace_item` (deletes the deployed item) or `deploy_workspace_item`
(promotes then drops the draft).
### Verification (done)
- **Unit (`core.test.ts`):** all 61 pass (was 24/58). A hoisted in-memory
`DraftService` mock (`draftDb`) backs the tools; assertions read drafts via
`dbDraftValue(...)` (the DB store) and seed setup via `seedDbDraft(...)`. The
`getMeta` assertions were dropped (rev meta isn't DB-persisted); secret-safety
now also asserts against `dbSnapshot()`.
- **Real backend round-trip** (dev `test` workspace, `force`): `get_draft` 404 →
`save_draft` (`{status:'saved',current_timestamp}`) → `get_draft`
(`{value,saved_at}`) → `list_drafts` (`{path,typ,saved_at}`) → `save_draft`
`value:null``get_draft` 404. Confirms the exact request/response shapes the
seam uses against the endpoints the unit tests mock.
### Hardening (post-review)
The original seam did **save → read-back**: `saveGlobalDraftValue` pushed to
`UserDraftDbSyncer.save`, then the writer re-read via `getRequiredGlobalDraft`
to shape its return value. Two problems with that:
1. **A failed write was reported as success.** `UserDraftDbSyncer.postSave`
swallows every error (`catch → console.error`) — correct for fire-and-forget
autosave, wrong for an awaited write whose result is reported to the model.
On a new draft a failed save surfaced as a misleading *"Could not read written
draft"*; on an **overwrite** the read-back returned the **stale** server copy
and the tool reported success with old content (silent write loss).
2. **Every write cost a POST + a GET** to reconstruct a value already in hand.
Both are closed:
- **Errors propagate.** `postSave` is split into a throwing `performSave` core
and the swallow-and-log `postSave` wrapper. A new `throwOnError` opt (honored
**only** on the awaited `immediate` path) routes to `performSave`, so a failed
POST rejects the `save()` promise. The headless `saveGlobalDraftValue`,
`deleteGlobalDraft`, and `clearGlobalDrafts` pass `throwOnError: true`; the
debounced autosave path and `overwrite(...)` keep the swallow (default
`false`) so they can't reject unhandled.
- **No read-back.** New `shapeGlobalDraftItem(workspace, type, path, value,
triggerKind?)` shapes the `WorkspaceItem` from the value just saved (same
`liveDisplayPath`/`isLiveDraft` logic as `getGlobalDraftSlot`, factored into a
shared `buildGlobalDraftItem`). `getRequiredGlobalDraft` is replaced by the
sync `requireWrittenDraftItem`; the six write handlers hoist their built draft
value and shape locally. `saveGlobalAppDraft` shapes locally too. Secret
variables are unaffected — the shaped value carries `value: ''` (the real
secret stays in the ephemeral map), same as the read-back produced.
Unit suite stays green (`core.test.ts` 58/58).
### Remaining
- **E2E with the live LLM-driven chat** (§8 step 5) is still unrun — it needs a
copilot API key and `wm_dev_global_ai` enabled; not exercisable headlessly here.
- Schedule/trigger/resource/variable drafts remain undiscoverable via
`list_workspace_items` (reachable by path through `read_workspace_item`) until
those list endpoints gain email-scoped `includeDraftOnly` (§6 limitation).
## 8. Environment (branch `claude-change-ed070a5a`)
= PR #9351 (`remove-workspace-drafts`, head `9c8c4edb`) + a merge of latest
`origin/main` + the tool #1 commit + the tools #2#8 seam (§7). Already prepared
in this worktree:
- DB migration `20260528143710_draft_user_sync_schema` is **applied** to the dev DB
(`windmill_claude_change_ed070a5a`). A fresh DB just needs `sqlx migrate run`.
- TS client was **regenerated** (`npm run generate-backend-client`) — `frontend/src/lib/gen`
is gitignored, so a fresh checkout must regenerate it to get `DraftService.{saveDraft,
getDraft, listDrafts, getDraftForUser}`.
- Dev server runs with `REMOTE=http://localhost:8070 PORT=3070`.
@@ -26,6 +26,30 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({
vi.mock('$lib/components/vscode', () => ({}))
// In-memory stand-in for the per-user DB draft layer (`DraftService`).
// Post #9351 the global mode is headless and persists drafts through the
// DB endpoints, so the tools read/write here instead of localStorage.
// Hoisted so the `$lib/gen` mock factory (itself hoisted) can close over it.
const draftDb = vi.hoisted(() => {
const store = new Map<string, { value: unknown; saved_at: string }>()
let clock = 0
const key = (kind: string, path: string) => `${kind}/${path}`
return {
store,
key,
reset() {
store.clear()
clock = 0
},
set(kind: string, path: string, value: unknown): string {
clock += 1
const saved_at = `2026-01-01T00:00:${String(clock).padStart(2, '0')}.000Z`
store.set(key(kind, path), { value, saved_at })
return saved_at
}
}
})
vi.mock('$lib/gen', async () => {
const actual = await vi.importActual<any>('$lib/gen')
@@ -48,12 +72,6 @@ vi.mock('$lib/gen', async () => {
getScriptByPath: vi.fn(async () => {
throw new Error('getScriptByPath mock not configured')
}),
getScriptByHash: vi.fn(async () => {
throw new Error('getScriptByHash mock not configured')
}),
getScriptByPathWithDraft: vi.fn(async () => {
throw new Error('getScriptByPathWithDraft mock not configured')
}),
queryHubScripts: vi.fn(async () => []),
getHubScriptContentByPath: vi.fn(async () => ''),
listScripts: vi.fn(async () => [])
@@ -76,9 +94,6 @@ vi.mock('$lib/gen', async () => {
getFlowByPath: vi.fn(async () => {
throw new Error('getFlowByPath mock not configured')
}),
getFlowByPathWithDraft: vi.fn(async () => {
throw new Error('getFlowByPathWithDraft mock not configured')
}),
getFlowLatestVersion: vi.fn(async () => ({ id: 1 })),
listFlows: vi.fn(async () => [])
}),
@@ -98,8 +113,8 @@ vi.mock('$lib/gen', async () => {
existsApp: vi.fn(async () => false),
createAppRaw: vi.fn(async () => 'created'),
updateAppRaw: vi.fn(async () => 'updated'),
getAppByPathWithDraft: vi.fn(async () => {
throw new Error('getAppByPathWithDraft mock not configured')
getAppByPath: vi.fn(async () => {
throw new Error('getAppByPath mock not configured')
}),
listApps: vi.fn(async () => [])
}),
@@ -116,6 +131,27 @@ vi.mock('$lib/gen', async () => {
}),
createVariable: vi.fn(async () => 'created'),
updateVariable: vi.fn(async () => 'updated')
}),
DraftService: wrapService(actual.DraftService, {
saveDraft: vi.fn(async ({ kind, path, requestBody }: any) => {
if (requestBody.value === null) {
draftDb.store.delete(draftDb.key(kind, path))
return { status: 'saved', current_timestamp: '1970-01-01T00:00:00.000Z' }
}
const saved_at = draftDb.set(kind, path, requestBody.value)
return { status: 'saved', current_timestamp: saved_at }
}),
getDraft: vi.fn(async ({ kind, path }: any) => {
const entry = draftDb.store.get(draftDb.key(kind, path))
if (!entry) {
throw new actual.ApiError(
{ method: 'GET', url: '' },
{ url: '', ok: false, status: 404, statusText: 'Not Found', body: undefined },
'no draft for the current user at that path'
)
}
return { value: entry.value, saved_at: entry.saved_at }
})
})
}
})
@@ -137,7 +173,7 @@ import {
setOpenPreviewHandler
} from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { clearGlobalDrafts } from './userDraftAdapter'
import { clearEphemeralSecretVariableDraftValues } from './userDraftAdapter'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
AppService,
@@ -190,6 +226,31 @@ function localStorageSnapshot(): string {
return values.join('\n')
}
// Read the value a tool persisted into the per-user DB draft layer. Post
// #9351 the headless global tools write through `DraftService`
// (mocked above as `draftDb`), not the in-tab `UserDraft` cache, so draft
// assertions read here instead of `UserDraft.get`.
function dbDraftValue<T = any>(kind: string, path: string): T | undefined {
return draftDb.store.get(draftDb.key(kind, path))?.value as T | undefined
}
// Seed a draft directly into the DB layer (replaces test setup that used
// `UserDraft.save` to pre-populate a draft — that now debounces to the DB
// asynchronously, whereas this lands synchronously). The trailing
// `{ workspace }` arg the old `UserDraft.save` calls passed is accepted and
// ignored so the call sites convert by rename alone.
function seedDbDraft(kind: string, path: string, value: unknown, _opts?: unknown): void {
draftDb.set(kind, path, value)
}
// Serialized view of every persisted draft value — used to assert secret
// values never reach the draft store.
function dbSnapshot(): string {
return [...draftDb.store.entries()]
.map(([k, v]) => `${k}: ${JSON.stringify(v.value)}`)
.join('\n')
}
async function withCompletedTestJob<T>(run: () => Promise<T>): Promise<T> {
vi.useFakeTimers()
try {
@@ -202,10 +263,11 @@ async function withCompletedTestJob<T>(run: () => Promise<T>): Promise<T> {
}
describe('global AI tools', () => {
beforeEach(() => {
beforeEach(async () => {
__resetUserDraftForTesting()
localStorage.clear()
clearGlobalDrafts(WORKSPACE)
draftDb.reset()
clearEphemeralSecretVariableDraftValues(WORKSPACE)
vi.clearAllMocks()
})
@@ -282,6 +344,7 @@ describe('global AI tools', () => {
expect(raw).not.toContain('super-secret-token')
expect(localStorageSnapshot()).not.toContain('super-secret-token')
expect(dbSnapshot()).not.toContain('super-secret-token')
expect(item).toEqual({
type: 'variable',
path: 'f/secrets/api_key',
@@ -308,7 +371,7 @@ describe('global AI tools', () => {
resource_type: 'postgresql'
})
expect(UserDraft.get<any>('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
expect(dbDraftValue('resource', 'f/resources/db')).toEqual({
path: 'f/resources/db',
description: 'existing database',
args: { host: 'new.example.com', port: 5432 },
@@ -316,9 +379,7 @@ describe('global AI tools', () => {
wsSpecific: true,
resource_type: 'postgresql'
})
expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
remoteRev: '2026-05-22T09:30:00Z'
})
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
})
it('writes variable drafts in the editor UserDraft shape', async () => {
@@ -343,7 +404,7 @@ describe('global AI tools', () => {
description: 'new description'
})
expect(UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
expect(dbDraftValue('variable', 'f/secrets/api_key')).toEqual({
path: 'f/secrets/api_key',
variable: {
value: '',
@@ -356,10 +417,9 @@ describe('global AI tools', () => {
is_oauth: true,
expires_at: '2026-06-22T09:30:00Z'
})
expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
remoteRev: '2026-05-22T09:30:00Z'
})
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
expect(localStorageSnapshot()).not.toContain('new-secret-token')
expect(dbSnapshot()).not.toContain('new-secret-token')
})
it('deploys secret variable drafts with ephemeral values only', async () => {
@@ -371,7 +431,7 @@ describe('global AI tools', () => {
})
expect(
UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
dbDraftValue('variable', 'f/secrets/api_key')
).toMatchObject({
path: 'f/secrets/api_key',
variable: {
@@ -382,6 +442,7 @@ describe('global AI tools', () => {
wsSpecific: false
})
expect(localStorageSnapshot()).not.toContain('new-secret-token')
expect(dbSnapshot()).not.toContain('new-secret-token')
await callGlobalTool('deploy_workspace_item', {
type: 'variable',
@@ -398,12 +459,13 @@ describe('global AI tools', () => {
ws_specific: false
})
})
expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined()
expect(dbDraftValue('variable', 'f/secrets/api_key')).toBeUndefined()
expect(localStorageSnapshot()).not.toContain('new-secret-token')
expect(dbSnapshot()).not.toContain('new-secret-token')
})
it('does not deploy a secret variable draft when the ephemeral value is gone', async () => {
UserDraft.save(
seedDbDraft(
'variable',
'f/secrets/api_key',
{
@@ -439,7 +501,7 @@ describe('global AI tools', () => {
content
})
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
expect(dbDraftValue('script', 'f/scripts/hello')).toMatchObject(
{
path: 'f/scripts/hello',
summary: 'Hello script',
@@ -449,19 +511,16 @@ describe('global AI tools', () => {
)
})
it('applies path_prefix to local drafts before enforcing the result limit', async () => {
await callGlobalTool('write_script', {
path: 'f/other/outside',
summary: 'Outside draft',
language: 'bun',
content: 'export async function main() { return "outside" }'
})
await callGlobalTool('write_script', {
path: 'f/matching/inside',
summary: 'Inside draft',
language: 'bun',
content: 'export async function main() { return "inside" }'
})
it('flags backend draft scripts and forwards path_prefix + limit', async () => {
// Post #9351 the list tool sources script drafts from `listScripts`
// (`includeDraftOnly` + the `draft_only`/`is_draft` flags), not a local
// store — so path_prefix (pathStart) and limit (perPage) are forwarded to
// the backend query, which does the filtering. The backend now filters
// draft-only rows by `path_start` too (the (c) fix in scripts/flows/apps),
// so a draft-only row under the prefix is returned and flagged here.
vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([
{ path: 'f/matching/inside', summary: 'Inside draft', language: 'bun', draft_only: true }
] as any)
const raw = await callGlobalTool('list_workspace_items', {
types: ['script'],
@@ -469,6 +528,9 @@ describe('global AI tools', () => {
limit: 1
})
expect(ScriptService.listScripts).toHaveBeenCalledWith(
expect.objectContaining({ pathStart: 'f/matching/', perPage: 1, includeDraftOnly: true })
)
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'script',
@@ -478,10 +540,29 @@ describe('global AI tools', () => {
])
})
it('requests draft-only apps and flags them via is_draft', async () => {
// Regression guard: `listApps` must be called with `includeDraftOnly`
// (the app call previously omitted it, so DB-only app drafts were missed),
// and a draft row maps to `isDraft: true` (draft-only apps carry
// `is_draft === true`).
vi.mocked(AppService.listApps).mockResolvedValueOnce([
{ path: 'f/apps/inflight', summary: 'In-flight app', draft_only: true, is_draft: true }
] as any)
const raw = await callGlobalTool('list_workspace_items', { types: ['app'] })
expect(AppService.listApps).toHaveBeenCalledWith(
expect.objectContaining({ includeDraftOnly: true })
)
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({ type: 'app', path: 'f/apps/inflight', isDraft: true })
])
})
it('lists and edits the live script editor draft through its effective path', async () => {
UserDraft.save(
seedDbDraft(
'script',
'',
'u/admin/draft_amazed',
{
path: 'u/admin/amazed_script',
summary: 'Live script',
@@ -497,7 +578,7 @@ describe('global AI tools', () => {
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'script',
storagePath: '',
storagePath: 'u/admin/draft_amazed',
effectivePath: 'u/admin/amazed_script'
})
@@ -517,19 +598,19 @@ describe('global AI tools', () => {
new_string: 'return a * b'
})
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })).toMatchObject({
expect(dbDraftValue('script', 'u/admin/draft_amazed')).toMatchObject({
path: 'u/admin/amazed_script',
content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}'
})
expect(
UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE })
dbDraftValue('script', 'u/admin/amazed_script')
).toBeUndefined()
})
it('lists and writes the live flow editor draft through its effective path', async () => {
UserDraft.save(
seedDbDraft(
'flow',
'',
'u/admin/draft_live_flow',
{
path: '',
summary: 'Live flow',
@@ -545,7 +626,7 @@ describe('global AI tools', () => {
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'flow',
storagePath: '',
storagePath: 'u/admin/draft_live_flow',
effectivePath: 'u/admin/live_flow'
})
@@ -565,18 +646,18 @@ describe('global AI tools', () => {
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', '', { workspace: WORKSPACE })).toMatchObject({
expect(dbDraftValue('flow', 'u/admin/draft_live_flow')).toMatchObject({
path: 'u/admin/live_flow',
summary: 'Updated live flow',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined()
expect(dbDraftValue('flow', 'u/admin/live_flow')).toBeUndefined()
})
it('writes the live raw app editor draft through its effective path', async () => {
UserDraft.save(
seedDbDraft(
'raw_app',
'',
'u/admin/draft_live_app',
{
summary: 'Live app',
files: { '/src/App.tsx': 'export default function App() { return null }' },
@@ -588,7 +669,7 @@ describe('global AI tools', () => {
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'raw_app',
storagePath: '',
storagePath: 'u/admin/draft_live_app',
effectivePath: 'u/admin/live_app'
})
@@ -598,16 +679,16 @@ describe('global AI tools', () => {
content: 'export default function New() { return null }'
})
expect(UserDraft.get<any>('raw_app', '', { workspace: WORKSPACE })).toMatchObject({
expect(dbDraftValue('raw_app', 'u/admin/draft_live_app')).toMatchObject({
files: {
'/src/App.tsx': 'export default function App() { return null }',
'/src/New.tsx': 'export default function New() { return null }'
}
})
expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
expect(dbDraftValue('raw_app', 'u/admin/live_app')).toBeUndefined()
})
it('discards a local draft without deleting the workspace item', async () => {
it('discards a draft without deleting the workspace item', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/discard-me',
summary: 'Temporary draft',
@@ -615,9 +696,9 @@ describe('global AI tools', () => {
content: 'export async function main() { return 1 }'
})
expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined()
expect(dbDraftValue('script', 'f/scripts/discard-me')).toBeDefined()
const raw = await callGlobalTool('discard_local_draft', {
const raw = await callGlobalTool('discard_draft', {
type: 'script',
path: 'f/scripts/discard-me'
})
@@ -629,13 +710,13 @@ describe('global AI tools', () => {
})
expect(raw).toContain('The deployed workspace item was not changed')
expect(
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
dbDraftValue('script', 'f/scripts/discard-me')
).toBeUndefined()
})
it('requires trigger_kind when discarding a trigger draft', async () => {
await expect(
callGlobalTool('discard_local_draft', {
callGlobalTool('discard_draft', {
type: 'trigger',
path: 'f/routes/missing-kind'
})
@@ -644,23 +725,14 @@ describe('global AI tools', () => {
it('preserves existing script metadata and seeds freshness on first script write', async () => {
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true)
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/existing',
hash: 'deployed-hash',
draft_created_at: '2026-05-22T10:00:00Z',
summary: 'deployed summary',
description: 'deployed description',
content: 'old deployed content',
language: 'bun',
kind: 'script',
draft: {
path: 'f/scripts/existing',
summary: 'db draft summary',
description: 'db draft description',
content: 'old draft content',
language: 'bun',
kind: 'script'
}
kind: 'script'
} as any)
await callGlobalTool('write_script', {
@@ -671,25 +743,22 @@ describe('global AI tools', () => {
})
expect(
UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
dbDraftValue('script', 'f/scripts/existing')
).toMatchObject({
path: 'f/scripts/existing',
parent_hash: 'deployed-hash',
summary: 'new summary',
description: 'db draft description',
description: 'deployed description',
content: 'new content',
language: 'bun'
})
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 'deployed-hash',
remoteDraftRev: '2026-05-22T10:00:00Z'
})
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
})
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any)
vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
path: 'f/flows/existing',
summary: 'deployed summary',
description: 'deployed description',
@@ -698,19 +767,7 @@ describe('global AI tools', () => {
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
archived: false,
extra_perms: {},
draft_created_at: '2026-05-22T10:00:00Z',
draft: {
path: 'f/flows/existing',
summary: 'db draft summary',
description: 'db draft description',
value: { modules: [] },
schema: { properties: { draft: { type: 'string' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:30:00Z',
archived: false,
extra_perms: {}
}
extra_perms: {}
} as any)
await callGlobalTool('write_flow', {
@@ -719,16 +776,13 @@ describe('global AI tools', () => {
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
expect(dbDraftValue('flow', 'f/flows/existing')).toMatchObject({
path: 'f/flows/existing',
summary: 'new summary',
description: 'db draft description',
description: 'deployed description',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 42,
remoteDraftRev: '2026-05-22T10:00:00Z'
})
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
})
it('preserves editor schedule fields when writing over an existing schedule', async () => {
@@ -762,7 +816,7 @@ describe('global AI tools', () => {
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
dbDraftValue('trigger_schedule', 'f/schedules/nightly')
).toMatchObject({
path: 'f/schedules/nightly',
schedule: '0 15 0 * * *',
@@ -777,7 +831,7 @@ describe('global AI tools', () => {
no_flow_overlap: true
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
dbDraftValue('trigger_schedule', 'f/schedules/nightly')
).not.toMatchObject({
edited_by: expect.anything()
})
@@ -820,7 +874,7 @@ describe('global AI tools', () => {
}
})
const draft = UserDraft.get<any>('trigger_http', 'f/routes/api', { workspace: WORKSPACE })
const draft = dbDraftValue('trigger_http', 'f/routes/api')
expect(draft).toMatchObject({
path: 'f/routes/api',
script_path: 'f/flows/new',
@@ -840,32 +894,22 @@ describe('global AI tools', () => {
})
it('seeds raw app draft metadata on first app write', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [3, 4],
draft_created_at: '2026-05-22T10:30:00Z',
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
},
policy: { execution_mode: 'publisher' },
custom_path: 'report',
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
},
policy: { execution_mode: 'anonymous' }
}
custom_path: 'report'
} as any)
await callGlobalTool('write_app_file', {
@@ -874,11 +918,11 @@ describe('global AI tools', () => {
content: 'export default function New() { return null }'
})
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
const draft = dbDraftValue('raw_app', 'f/apps/report')
expect(draft).toMatchObject({
summary: 'saved app draft',
summary: 'deployed app',
files: {
'/src/App.tsx': 'draft content',
'/src/App.tsx': 'deployed content',
'/src/New.tsx': 'export default function New() { return null }'
},
runnables: {
@@ -888,17 +932,14 @@ describe('global AI tools', () => {
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
policy: { execution_mode: 'anonymous' },
policy: { execution_mode: 'publisher' },
custom_path: 'report'
})
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
remoteRev: 4,
remoteDraftRev: '2026-05-22T10:30:00Z'
})
// Rev metadata (remoteRev) is in-memory only post #9351 — not persisted to the DB draft layer.
})
it('summarizes local raw app drafts in read_workspace_item', async () => {
UserDraft.save(
seedDbDraft(
'raw_app',
'f/apps/local',
{
@@ -948,39 +989,31 @@ describe('global AI tools', () => {
expect(item.value.backend[0]).not.toHaveProperty('content')
})
it('summarizes backend raw app drafts from the same source as file reads', async () => {
const appWithDraft = {
it('summarizes backend raw apps from the same source as file reads', async () => {
const deployedApp = {
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: ['deployed'] }
},
draft: {
summary: 'saved app draft',
value: {
files: {
'/src/App.tsx': 'draft content',
'/src/DraftOnly.tsx': 'draft-only content'
},
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'export async function main() { return "draft" }'
}
files: {
'/src/App.tsx': 'deployed content',
'/src/Helper.tsx': 'helper content'
},
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'export async function main() { return "deployed" }'
}
},
data: { tables: ['draft'] }
}
}
},
data: { tables: ['deployed'] }
}
}
vi.mocked(AppService.getAppByPathWithDraft)
.mockResolvedValueOnce(appWithDraft as any)
.mockResolvedValueOnce(appWithDraft as any)
vi.mocked(AppService.getAppByPath)
.mockResolvedValueOnce(deployedApp as any)
.mockResolvedValueOnce(deployedApp as any)
const raw = await callGlobalTool('read_workspace_item', {
type: 'app',
@@ -988,15 +1021,14 @@ describe('global AI tools', () => {
})
const item = JSON.parse(raw)
expect(raw).not.toContain('draft-only content')
expect(item).toMatchObject({
type: 'app',
path: 'f/apps/report',
summary: 'saved app draft',
summary: 'deployed app',
value: {
frontend: [
{ path: '/src/App.tsx', size: 'draft content'.length },
{ path: '/src/DraftOnly.tsx', size: 'draft-only content'.length }
{ path: '/src/App.tsx', size: 'deployed content'.length },
{ path: '/src/Helper.tsx', size: 'helper content'.length }
],
backend: [
expect.objectContaining({
@@ -1004,10 +1036,10 @@ describe('global AI tools', () => {
name: 'main',
type: 'inline',
language: 'bun',
contentSize: 'export async function main() { return "draft" }'.length
contentSize: 'export async function main() { return "deployed" }'.length
})
],
data: { tables: ['draft'] }
data: { tables: ['deployed'] }
},
isDraft: false
})
@@ -1015,14 +1047,14 @@ describe('global AI tools', () => {
await expect(
callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/src/DraftOnly.tsx'
file_path: '/src/Helper.tsx'
})
).resolves.toBe('draft-only content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
).resolves.toBe('helper content')
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
})
it('reads raw app files without creating a local draft', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
it('reads raw app files without creating a draft', async () => {
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
@@ -1030,14 +1062,6 @@ describe('global AI tools', () => {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
},
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: {},
data: { tables: [] }
}
}
} as any)
@@ -1046,12 +1070,12 @@ describe('global AI tools', () => {
path: 'f/apps/report',
file_path: '/src/App.tsx'
})
).resolves.toBe('draft content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
).resolves.toBe('deployed content')
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
})
it('does not persist a raw app draft when patch_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
@@ -1071,11 +1095,11 @@ describe('global AI tools', () => {
replace_all: false
})
).rejects.toThrow()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
})
it('does not persist a raw app draft when delete_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
@@ -1092,11 +1116,11 @@ describe('global AI tools', () => {
file_path: '/src/Missing.tsx'
})
).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
})
it('does not persist a raw app draft when delete_app_runnable validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
@@ -1118,11 +1142,11 @@ describe('global AI tools', () => {
key: 'missing'
})
).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
})
it('deploys a new raw app draft by bundling files and creating a raw app', async () => {
UserDraft.save(
seedDbDraft(
'raw_app',
'f/apps/report',
{
@@ -1174,7 +1198,7 @@ describe('global AI tools', () => {
}
})
expect(AppService.updateAppRaw).not.toHaveBeenCalled()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
expect(JSON.parse(raw)).toMatchObject({
success: true,
type: 'app',
@@ -1184,7 +1208,7 @@ describe('global AI tools', () => {
it('deploys an existing raw app draft by bundling files and updating the raw app', async () => {
vi.mocked(AppService.existsApp).mockResolvedValueOnce(true)
UserDraft.save(
seedDbDraft(
'raw_app',
'f/apps/report',
{
@@ -1223,14 +1247,14 @@ describe('global AI tools', () => {
}
})
expect(AppService.createAppRaw).not.toHaveBeenCalled()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
expect(dbDraftValue('raw_app', 'f/apps/report')).toBeUndefined()
})
it('notifies the session preview (as raw_app) after deploying a raw app', async () => {
const onDeployed = vi.fn()
setDeployedInSessionHandler(onDeployed)
try {
UserDraft.save(
seedDbDraft(
'raw_app',
'f/apps/report',
{
@@ -1354,7 +1378,7 @@ describe('global AI tools', () => {
expect(item.value.value).toBeUndefined()
})
it('test_run_script previews local draft script content by path', async () => {
it('test_run_script previews draft script content by path', async () => {
const content = 'export async function main(name: string) {\n\treturn `hello ${name}`\n}'
await callGlobalTool('write_script', {
path: 'f/scripts/draft-test',
@@ -1384,7 +1408,7 @@ describe('global AI tools', () => {
expect(result).toContain('test logs')
})
it('test_run_script previews deployed script content when no local draft exists', async () => {
it('test_run_script previews deployed script content when no draft exists', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/deployed-test',
summary: 'Deployed test script',
@@ -1414,7 +1438,7 @@ describe('global AI tools', () => {
})
})
it('test_run_flow previews local draft flow content by path', async () => {
it('test_run_flow previews draft flow content by path', async () => {
const modules = [{ id: 'start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
path: 'f/flows/draft-test',
@@ -1440,7 +1464,7 @@ describe('global AI tools', () => {
})
})
it('test_run_flow previews deployed flow content when no local draft exists', async () => {
it('test_run_flow previews deployed flow content when no draft exists', async () => {
const modules = [{ id: 'deployed_start', value: { type: 'identity' } }]
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
path: 'f/flows/deployed-test',
@@ -1471,9 +1495,9 @@ describe('global AI tools', () => {
})
it('test_run_flow uses the live flow editor test hook when the active editor matches the path', async () => {
UserDraft.save(
seedDbDraft(
'flow',
'',
'u/admin/draft_live_flow_hook',
{
path: 'u/admin/live_flow',
summary: 'Live flow',
@@ -1489,7 +1513,7 @@ describe('global AI tools', () => {
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'flow',
storagePath: '',
storagePath: 'u/admin/draft_live_flow_hook',
effectivePath: 'u/admin/live_flow'
})
const testActiveFlow = vi.fn(async () => 'job-live-flow')
@@ -1513,9 +1537,9 @@ describe('global AI tools', () => {
})
it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => {
UserDraft.save(
seedDbDraft(
'flow',
'',
'u/admin/draft_live_flow_fallback',
{
path: 'u/admin/live_flow_fallback',
summary: 'Live flow fallback',
@@ -1531,7 +1555,7 @@ describe('global AI tools', () => {
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'flow',
storagePath: '',
storagePath: 'u/admin/draft_live_flow_fallback',
effectivePath: 'u/admin/live_flow_fallback'
})
const testActiveFlow = vi.fn(async () => undefined)
@@ -1560,7 +1584,7 @@ describe('global AI tools', () => {
})
})
it('test_run_step previews rawscript steps from the local draft flow', async () => {
it('test_run_step previews rawscript steps from the draft flow', async () => {
const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}'
await callGlobalTool('write_flow', {
path: 'f/flows/rawscript-step',
@@ -1639,7 +1663,7 @@ describe('global AI tools', () => {
})
})
it('test_run_step previews local draft subflows for flow steps', async () => {
it('test_run_step previews draft subflows for flow steps', async () => {
const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
path: 'f/flows/nested-draft',
@@ -1830,9 +1854,9 @@ describe('prepareGlobalSystemMessage', () => {
const message = prepareGlobalSystemMessage()
const content = message.content
expect(content).toContain('Draft tools create or update local drafts only')
expect(content).toContain('Draft tools create or update drafts only')
expect(content).toContain(
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
'Use discard_draft to remove an unsaved draft, including the matching open editor draft'
)
expect(content).toContain(
'After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step'
@@ -1845,11 +1869,11 @@ describe('prepareGlobalSystemMessage', () => {
})
it('exposes separate tools for discarding drafts and deleting workspace items', () => {
const discard = getGlobalTool('discard_local_draft')
const discard = getGlobalTool('discard_draft')
const deleteItem = getGlobalTool('delete_workspace_item')
expect(discard.def.function.description).toBe(
'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
'Discard a draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
)
expect(deleteItem.def.function.description).toBe(
'Delete a deployed workspace item. Mutates the workspace.'
@@ -1922,7 +1946,7 @@ describe('prepareGlobalUserMessage', () => {
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'script',
storagePath: '',
storagePath: 'f/scripts/draft_live_greeting',
effectivePath: 'f/scripts/live_greeting'
})
File diff suppressed because it is too large Load Diff
@@ -10,9 +10,8 @@
*
* When the mode is ready to ship to everyone, replace every call to
* `isGlobalAiEnabled()` with `true` and delete this file. The references are
* intentionally narrow (chat mode visibility, custom prompt settings, the
* `change_mode` tool enum, and the `/global_drafts` dev route) so the rip-out
* is a small grep.
* intentionally narrow (chat mode visibility, custom prompt settings, and the
* `change_mode` tool enum) so the rip-out is a small grep.
*/
const STORAGE_KEY = 'wm_dev_global_ai'
@@ -1,4 +1,5 @@
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
import { ApiError, DraftService } from '$lib/gen'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import {
UserDraft,
@@ -6,8 +7,8 @@ import {
type UserDraftItemKind,
type UserDraftMeta
} from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import {
getWorkspaceItemKey,
type AppDraftValue,
type ResourceDraftState,
type TriggerKind,
@@ -36,24 +37,6 @@ const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries(
])
) as Partial<Record<UserDraftItemKind, TriggerKind>>
const GLOBAL_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 secretVariableDraftValues = new Map<string, Map<string, string>>()
function clone<T>(value: T): T {
@@ -102,7 +85,7 @@ export function clearEphemeralSecretVariableDraftValue(workspace: string, path:
if (workspaceValues.size === 0) secretVariableDraftValues.delete(workspace)
}
function clearEphemeralSecretVariableDraftValues(workspace: string): void {
export function clearEphemeralSecretVariableDraftValues(workspace: string): void {
secretVariableDraftValues.delete(workspace)
}
@@ -283,7 +266,14 @@ function resolveDraftStoragePath(
path: string
): string {
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (!liveDraft) return path
// Fall back to the caller's path when there's no live editor, or when
// the live editor hasn't committed a storage path yet. An empty
// storage path must never propagate to the DB seam: it can't be a
// `draft` row key and the draft routes (`/save_draft/{kind}/{*path}`)
// 404 on an empty wildcard tail. In practice new drafts always live at
// a real `u/{user}/draft_{uuid}` path (see `/scripts/add` et al.), so
// this is defensive — but it keeps `''` out of `DraftService` outright.
if (!liveDraft || !liveDraft.storagePath) return path
if (path === liveDraft.storagePath || path === liveDraft.effectivePath)
return liveDraft.storagePath
return path
@@ -299,7 +289,130 @@ export function getGlobalDraftStoragePath(
return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path
}
function getGlobalDraftSlot(
function isNotFoundError(e: unknown): boolean {
return e instanceof ApiError && e.status === 404
}
/**
* Raw draft value for `(workspace, itemKind, storagePath)`. The in-tab
* mounted cell wins it's the freshest, holding any unsaved live-editor
* edits that haven't been flushed to the server yet otherwise the
* per-user DB draft. Returns `undefined` when no draft exists in either
* place. Async because the headless fallback is a fetch.
*
* Post #9351 the global mode is headless (never mounts a handle), so the
* in-memory branch only hits when an editor is open on the same item; the
* DB branch is the common one and is what makes a draft written in a
* previous turn / tab readable here.
*/
export async function readGlobalDraftValue<V>(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string
): Promise<V | undefined> {
const local = UserDraft.get<V>(itemKind, storagePath, { workspace })
if (local !== undefined) return local
try {
const resp = await DraftService.getDraft({ workspace, kind: itemKind, path: storagePath })
return resp.value as V
} catch (e) {
if (isNotFoundError(e)) return undefined
throw e
}
}
/**
* Persist a draft value for `(workspace, itemKind, storagePath)`.
*
* When an editor handle is mounted for this entry, route through the
* in-memory `UserDraft` layer so the open editor reflects the write
* reactively (its background syncer still persists it). When headless
* the common path for AI-driven writes push straight to the DB and
* AWAIT it, so a read-back immediately after sees the value: this closes
* the read-after-write gap (`UserDraft.save` alone never seeds the in-tab
* cell for an unmounted entry, so a subsequent `UserDraft.get` would
* return `undefined`). Forced AI overwrites have no human at a conflict
* modal. Rev metadata is in-memory only and is dropped on the headless
* path (the DB stores values, not revs); the next editor mount reseeds it.
*/
export async function saveGlobalDraftValue<V>(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string,
value: V,
meta?: UserDraftMeta
): Promise<void> {
if (UserDraft.isLive(itemKind, storagePath, { workspace })) {
if (meta) {
UserDraft.setDraftAndMeta(itemKind, storagePath, value, meta, { workspace })
} else {
UserDraft.save(itemKind, storagePath, value, { workspace })
}
return
}
await UserDraftDbSyncer.save({
workspace,
itemKind,
path: storagePath,
value,
immediate: true,
force: true,
// Headless write: a failed POST must reject so the calling tool
// reports the failure, instead of being silently swallowed and
// then read back as a stale value (or 404) and mis-reported as a
// successful write.
throwOnError: true
})
}
/**
* Shape a draft `value` into a `WorkspaceItem` at its live-editor display
* path. Shared by the DB read path (`getGlobalDraftSlot`) and the
* write-then-shape path (`shapeGlobalDraftItem`) so both yield an
* identical item for the same value.
*/
function buildGlobalDraftItem<V>(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string,
value: V
): { displayPath: string; item: WorkspaceItem } | undefined {
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
const entry: UserDraftEntry = {
workspace,
itemKind,
path: storagePath,
value,
meta: {}
}
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
return item ? { displayPath, item } : undefined
}
/**
* Shape a freshly-written draft `value` into a `WorkspaceItem` WITHOUT a
* server read-back. The value is exactly what `saveGlobalDraftValue` just
* persisted (and that save now rejects on failure), so reading it back
* would be a redundant round trip and, before the syncer learned to
* reject failed writes, the read-back could mask a failed save by
* returning the stale server copy. Mirrors `getGlobalDraftSlot`'s shaping
* (same live-editor display-path / `isLiveDraft` resolution), sourced
* from the in-hand value.
*/
export function shapeGlobalDraftItem<V>(
workspace: string,
type: WorkspaceItemType,
path: string,
value: V,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
return buildGlobalDraftItem(workspace, itemKind, storagePath, value)?.item
}
async function getGlobalDraftSlot(
workspace: string,
type: WorkspaceItemType,
path: string,
@@ -308,59 +421,88 @@ function getGlobalDraftSlot(
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const draft = UserDraft.get(itemKind, storagePath, { workspace })
const draft = await readGlobalDraftValue(workspace, itemKind, storagePath)
if (draft === undefined) return undefined
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
const entry = {
workspace,
itemKind,
path: storagePath,
value: draft,
meta: {},
persisted: false,
live: false
}
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
if (!item) return undefined
return { itemKind, storagePath, displayPath, item }
const shaped = buildGlobalDraftItem(workspace, itemKind, storagePath, draft)
if (!shaped) return undefined
return { itemKind, storagePath, displayPath: shaped.displayPath, item: shaped.item }
}
export function getGlobalDraft(
export async function getGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item
): Promise<WorkspaceItem | undefined> {
return (await getGlobalDraftSlot(workspace, type, path, triggerKind))?.item
}
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
const drafts = new Map<string, WorkspaceItem>()
for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path)
const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
if (!draft) continue
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
const LIVE_EDITOR_DRAFT_KINDS = [
'script',
'flow',
'raw_app'
] as const satisfies readonly UserDraftItemKind[]
function liveEditorDraftType(kind: (typeof LIVE_EDITOR_DRAFT_KINDS)[number]): WorkspaceItemType {
return kind === 'raw_app' ? 'app' : kind
}
/**
* The open editor's in-flight draft for each editor kind (script/flow/raw_app)
* as a value-less `WorkspaceItem` at its *effective* path, flagged
* `isLiveDraft`.
*
* This is in-memory state that the DB draft list can't represent: a brand-new
* draft that hasn't been saved to the server yet has no DB row to list, and an
* in-progress rename's effective path differs from the `u/{user}/draft_{uuid}`
* path where the draft is stored. Persisted draft listing comes from the
* backend (`includeDraftOnly` + `isDraft`); this only fills that in-memory gap.
*
* Existence and path are taken straight from the live registry
* (`getLiveEditorDraft`) not gated on the in-tab value cell, which is only
* populated while the editor's handle is mounted. `summary` is best-effort
* from that cell when present. `storagePath` is returned so the caller can drop
* a stale list entry at the pre-rename path.
*/
export function listLiveEditorDrafts(
workspace: string
): { item: WorkspaceItem; storagePath: string }[] {
const out: { item: WorkspaceItem; storagePath: string }[] = []
for (const itemKind of LIVE_EDITOR_DRAFT_KINDS) {
const live = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (!live) continue
const path = live.effectivePath || live.storagePath
if (!path) continue
const value = UserDraft.get(itemKind, live.storagePath, { workspace })
out.push({
item: {
type: liveEditorDraftType(itemKind),
path,
summary: getItemSummary(value),
isDraft: true,
isLiveDraft: true
},
storagePath: live.storagePath
})
}
return Array.from(drafts.values())
return out
}
export function saveGlobalAppDraft(
export async function saveGlobalAppDraft(
workspace: string,
path: string,
value: AppDraftValue,
meta?: UserDraftMeta
): WorkspaceItem {
): Promise<WorkspaceItem> {
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
const normalized = normalizeAppDraftValue(value)
if (meta) {
UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace })
} else {
UserDraft.save('raw_app', storagePath, normalized, { workspace })
}
const stored = getGlobalDraft(workspace, 'app', path)
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
await saveGlobalDraftValue(workspace, 'raw_app', storagePath, normalized, meta)
// Shape from the value we just persisted instead of reading it back —
// the save above rejects on failure, so a read-back would only add a
// round trip (and risk returning a stale copy).
const stored = shapeGlobalDraftItem(workspace, 'app', path, normalized)
if (!stored) throw new Error(`Could not shape written app draft "${path}".`)
return stored
}
@@ -368,28 +510,44 @@ type DeleteGlobalDraftOptions = {
preserveLiveDraft?: boolean
}
export function deleteGlobalDraft(
export async function deleteGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind,
options: DeleteGlobalDraftOptions = {}
): void {
): Promise<void> {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
UserDraft.remove(itemKind, storagePath, { workspace })
} else {
UserDraft.clear(itemKind, storagePath, { workspace })
if (UserDraft.isLive(itemKind, storagePath, { workspace })) {
// An editor is open on this draft: reset its in-memory cell so the
// UI reflects the delete. `remove`/`clear` also queue a *debounced*
// `value: null` sync, which the awaited immediate delete below
// supersedes — `immediate` cancels the queued debouncer task, so
// exactly one delete lands.
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
UserDraft.remove(itemKind, storagePath, { workspace })
} else {
UserDraft.clear(itemKind, storagePath, { workspace })
}
}
// Always delete the DB row with an awaited, immediate, forced write. The
// live branch above only clears the in-tab cell and queues a *debounced*
// null sync; without this, an immediate read-back would fall through
// `readGlobalDraftValue`'s empty-cell check to `DraftService.getDraft` and
// resurrect the still-present DB row until the debounce flushed.
await UserDraftDbSyncer.save({
workspace,
itemKind,
path: storagePath,
value: null,
immediate: true,
force: true,
// A failed delete must reject so callers (discard / deploy /
// delete tools) don't report "discarded" while the row survives.
throwOnError: true
})
if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath)
}
export function clearGlobalDrafts(workspace: string): void {
for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
UserDraft.clear(draft.itemKind, draft.path, { workspace })
}
clearEphemeralSecretVariableDraftValues(workspace)
}
+21 -21
View File
@@ -1,24 +1,24 @@
import type { Value } from "$lib/utils"
import type { Value } from '$lib/utils'
export type DiffDrawerDiff =
| {
mode: 'normal'
deployed: Value
draft: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
button?: { text: string; onClick: () => void }
}
| {
mode: 'simple'
original: Value
current: Value
title: string
button?: { text: string; onClick: () => void }
}
export type DiffDrawerDiff =
| {
mode: 'normal'
deployed: Value
draft?: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
button?: { text: string; onClick: () => void }
}
| {
mode: 'simple'
original: Value
current: Value
title: string
button?: { text: string; onClick: () => void }
}
export interface DiffDrawerI {
openDrawer: () => void
closeDrawer: () => void
setDiff: (diff: DiffDrawerDiff) => void
}
openDrawer: () => void
closeDrawer: () => void
setDiff: (diff: DiffDrawerDiff) => void
}
+6 -16
View File
@@ -1,7 +1,7 @@
import type { OpenFlow } from '$lib/gen'
import type { Flow, OpenFlow } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import type { FlowState } from './flows/flowState'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import type { Trigger } from './triggers/utils'
import type { DiffDrawerI } from './diff_drawer'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import type { ScheduleTrigger } from './triggers'
@@ -17,14 +17,13 @@ export type FlowBuilderProps = {
loading?: boolean
flowStore: StateStore<OpenFlow>
flowStateStore: StateStore<FlowState>
savedFlow?: FlowWithDraftAndDraftTriggers | undefined
savedFlow?: Flow | undefined
diffDrawer?: DiffDrawerI | undefined
customUi?: FlowBuilderWhitelabelCustomUi
disableAi?: boolean
disabledFlowInputs?: boolean
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
version?: number | undefined
setSavedraftCb?: ((cb: () => void) => void) | undefined
draftTriggersFromUrl?: Trigger[] | undefined
selectedTriggerIndexFromUrl?: number | undefined
children?: import('svelte').Snippet
@@ -34,21 +33,12 @@ export type FlowBuilderProps = {
}
noInitial?: boolean
liveEditorDraftStoragePath?: string
onSaveInitial?: ({ path, id }: { path: string; id: string }) => void
onSaveDraft?: ({
path,
savedAtNewPath,
newFlow
}: {
path: string
savedAtNewPath: boolean
newFlow: boolean
}) => void
onSaveDraftError?: ({ error }: { error: any }) => void
onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void
onDeploy?: ({ path }: { path: string }) => void
onDeployError?: ({ error }: { error: any }) => void
onDetails?: ({ path }: { path: string }) => void
onHistoryRestore?: () => void
onNavigate?: (item: WorkspaceItem) => void
// Threaded to the `AutosaveIndicator` popover so its "Reset to
// deployed" button can do the same thing the load-time toast offers.
onResetToDeployed?: () => void | Promise<void>
}
@@ -26,10 +26,10 @@
// Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation),
// so the in-memory importStore would be lost. Use sessionStorage instead.
sessionStorage.setItem('rawAppImport', JSON.stringify(parsed))
await goto('/apps_raw/add?nodraft=true')
await goto('/apps_raw/add')
} else {
$importStore = parsed
await goto('/apps/add?nodraft=true')
await goto('/apps/add')
}
drawer?.closeDrawer?.()
}
@@ -40,12 +40,12 @@
function selectLowCode() {
appTypeModalOpen = false
goto(`${base}/apps/add?nodraft=true`)
goto(`${base}/apps/add`)
}
function selectFullCode() {
appTypeModalOpen = false
goto(`${base}/apps_raw/add?nodraft=true`)
goto(`${base}/apps_raw/add`)
}
</script>
@@ -33,7 +33,7 @@
async function importRaw() {
$importFlowStore =
importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '')
await goto('/flows/add?nodraft=true')
await goto('/flows/add')
drawer?.closeDrawer?.()
}
@@ -41,13 +41,13 @@
const parsed =
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '')
$importScriptStore = parsed
await goto(`${base}/scripts/add?import=true&nodraft=true`)
await goto(`${base}/scripts/add?import=true`)
wacDrawer?.closeDrawer?.()
}
function handleFlowClick() {
if (skipModal) {
goto(`${base}/flows/add?nodraft=true`)
goto(`${base}/flows/add`)
} else {
flowModalOpen = true
}
@@ -55,17 +55,17 @@
function selectFlowEditor() {
flowModalOpen = false
goto(`${base}/flows/add?nodraft=true`)
goto(`${base}/flows/add`)
}
function selectWacPython() {
flowModalOpen = false
goto(`${base}/scripts/add?nodraft=true&wac=python`)
goto(`${base}/scripts/add?wac=python`)
}
function selectWacTypescript() {
flowModalOpen = false
goto(`${base}/scripts/add?nodraft=true&wac=typescript`)
goto(`${base}/scripts/add?wac=typescript`)
}
function toggleSkipModal() {
@@ -24,23 +24,14 @@
flowEditorDrawer?.openDrawer?.()
try {
const flowWithDraft = await FlowService.getFlowByPathWithDraft({
const backendFlow = await FlowService.getFlowByPath({
workspace: $workspaceStore!,
path
})
savedFlow = {
...structuredClone(flowWithDraft),
draft: flowWithDraft.draft
? {
...structuredClone(flowWithDraft.draft),
path: flowWithDraft.draft.path ?? flowWithDraft.path
}
: undefined
} as Flow & { draft?: Flow }
savedFlow = structuredClone(backendFlow) as Flow
// Use the draft if available, otherwise the deployed flow
flow = flowWithDraft.draft ?? flowWithDraft
flow = backendFlow
await initFlow(flow, flowStore, flowStateStore)
loading = false
@@ -53,11 +44,7 @@
let callback: (() => void) | undefined = undefined
let flowPath: string = $state('')
let flow: Flow | undefined = $state(undefined)
let savedFlow:
| (Flow & {
draft?: Flow | undefined
})
| undefined = $state(undefined)
let savedFlow: Flow | undefined = $state(undefined)
let loading = $state(true)
const flowStore: StateStore<Flow> = $state({
@@ -61,7 +61,6 @@
type?: U
time?: number
starred?: boolean
has_draft?: boolean
hash?: string
}
@@ -241,9 +240,13 @@
async function showCode(path: string, summary: string) {
viewCodeTitle = summary || path
await viewCodeDrawer?.openDrawer()
// `getDraft: true` so draft-only scripts (no deployed row at this
// path) still return their content via the per-user draft overlay
// instead of 404'ing.
script = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path
path,
getDraft: true
})
}
@@ -5,7 +5,6 @@ type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
type?: U
time?: number
starred?: boolean
has_draft?: boolean
}
type TableScript = TableItem<Script, 'script'>
@@ -86,6 +86,16 @@
* still toggle the mode after mount; this prop only seeds the
* initial state. */
defaultSplitWithPreview?: boolean
/** Set by `RawAppEditorHeader` whenever the user-typed path
* (`newEditedPath`) differs from the deployed/seeded `savedApp.path`.
* The route reads it back to inject `draft_path` into the autosaved
* raw-app value so the home-page row can render the friendly name
* instead of the URL's autogenerated `draft_{uuid}` slot. */
pendingDraftPath?: string | undefined
// Threaded through `RawAppEditorHeader` to the `AutosaveIndicator`
// popover so its "Reset to deployed" button can do the same thing
// the load-time toast offers.
onResetToDeployed?: () => void | Promise<void>
}
let {
@@ -104,7 +114,9 @@
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed',
liveEditorDraftStoragePath = undefined,
defaultSplitWithPreview = true
defaultSplitWithPreview = true,
pendingDraftPath = $bindable(undefined),
onResetToDeployed
}: Props = $props()
export const version: number | undefined = undefined
@@ -1357,6 +1369,7 @@
bind:jobsById
bind:savedApp
bind:summary
bind:pendingDraftPath
on:restore
on:savedNewAppPath
{policy}
@@ -1371,6 +1384,7 @@
{getBundle}
{onNavigate}
{onDeploy}
{onResetToDeployed}
canUndo={historyManager.canUndo}
canRedo={historyManager.canRedo}
onUndo={handleUndo}
@@ -1637,8 +1651,9 @@
title="Build failed"
class="relative before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-surface before:content-['']"
>
<pre
class="overflow-auto whitespace-pre-wrap text-xs max-h-60">{buildError}</pre>
<pre class="overflow-auto whitespace-pre-wrap text-xs max-h-60"
>{buildError}</pre
>
</Alert>
</div>
{/if}
@@ -5,7 +5,7 @@
import { editPathFor } from '$lib/components/workspacePicker'
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { AppService, type Policy } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { rawAppToHubUrl } from '$lib/hub'
import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
@@ -26,32 +26,25 @@
WandSparkles
} from 'lucide-svelte'
import { createEventDispatcher, untrack } from 'svelte'
import {
cleanValueProperties,
orderedJsonStringify,
type Value,
replaceFalseWithUndefined
} from '../../utils'
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils'
import { random_adj } from '$lib/components/random_positive_adjetive'
// import { allItems, toStatic } from '../apps/editor/settingsPanel/utils'
import AppExportButton from '../apps/editor/AppExportButton.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { sendUserToast } from '$lib/toast'
import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte'
import Awareness from '$lib/components/Awareness.svelte'
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
import EditorHeader from '$lib/components/EditorHeader.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import { goto } from '$app/navigation'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte'
import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes'
import DropdownV2 from '../DropdownV2.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import AppEditorHeaderDeployInitialDraft from '../apps/editor/AppEditorHeaderDeployInitialDraft.svelte'
import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte'
import type { Runnable } from './RawAppInlineScriptRunnable.svelte'
import { updateRawAppPolicy } from './rawAppPolicy'
@@ -103,11 +96,9 @@
savedApp?:
| {
value: any
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
@@ -136,6 +127,15 @@
liveEditorDraftStoragePath?: string
// Fired after a successful deploy; lets the session preview reload.
onDeploy?: (e: { path: string }) => void
/** Surfaces the user-typed path (`newEditedPath`) up to the route
* when (and only when) it differs from the deployed/seeded
* `savedApp.path`. The route writes it into the autosaved raw-app
* draft as `draft_path` so the home-page row can render the
* friendly name instead of the URL's autogenerated draft slot. */
pendingDraftPath?: string | undefined
// Threaded to the `AutosaveIndicator` popover so its "Reset to
// deployed" button can do the same thing the load-time toast offers.
onResetToDeployed?: () => void | Promise<void>
}
let {
@@ -162,9 +162,19 @@
onToggleSidebar = undefined,
onNavigate = undefined,
liveEditorDraftStoragePath = undefined,
onDeploy = undefined
onDeploy = undefined,
pendingDraftPath = $bindable(undefined),
onResetToDeployed
}: Props = $props()
$effect(() => {
const typed = newEditedPath
const baseline = savedApp?.path ?? ''
untrack(() => {
pendingDraftPath = typed && typed !== baseline ? typed : undefined
})
})
let newEditedPath = $state(
untrack(() =>
newApp
@@ -198,14 +208,12 @@
const loading = $state({
publish: false,
save: false,
saveDraft: false
save: false
})
let pathError: string = $state('')
let appExport = $state() as AppExportButton | undefined
let draftDrawerOpen = $state(false)
let saveDrawerOpen = $state(false)
let historyBrowserDrawerOpen = $state(false)
let publishToHubDrawerOpen = $state(false)
@@ -251,10 +259,6 @@
saveDrawerOpen = false
}
function closeDraftDrawer() {
draftDrawerOpen = false
}
async function computeTriggerables() {
policy = await updateRawAppPolicy(runnables, policy)
}
@@ -326,7 +330,7 @@
replaceFalseWithUndefined({
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
})
@@ -376,11 +380,10 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
}
@@ -458,175 +461,6 @@
return
}
async function saveInitialDraft() {
if (!app) {
sendUserToast(`App hasn't been loaded yet`, true)
return
}
await computeTriggerables()
try {
let { css, js } = await getBundle()
await AppService.createAppRaw({
workspace: $workspaceStore!,
formData: {
app: {
value: app,
path: newEditedPath,
summary: summary,
policy,
draft_only: true,
custom_path: customPath
},
js,
css
}
})
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: newEditedPath,
typ: 'app',
value: {
value: app,
path: newEditedPath,
summary: summary,
policy,
custom_path: customPath
}
}
})
savedApp = {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: newEditedPath,
policy,
draft_only: true,
draft: {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: newEditedPath,
policy,
custom_path: customPath
},
custom_path: customPath
}
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.
if (!inSessionPane) UserDraft.remove('raw_app', appPath)
dispatch('savedNewAppPath', newEditedPath)
} catch (e) {
sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true)
}
draftDrawerOpen = false
}
async function saveDraft(forceSave = false) {
if (!app) {
sendUserToast(`App hasn't been loaded yet`, true)
return
}
if (newApp) {
// initial draft
draftDrawerOpen = true
return
}
if (!savedApp) {
return
}
const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp)
const current = cleanValueProperties({
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
policy
})
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
label: 'Save anyway',
callback: () => {
saveDraft(true)
}
}
])
return
}
loading.saveDraft = true
try {
await computeTriggerables()
let path = appPath
if (savedApp.draft_only) {
await AppService.deleteApp({
workspace: $workspaceStore!,
path: path
})
let { css, js } = await getBundle()
await AppService.createAppRaw({
workspace: $workspaceStore!,
formData: {
app: {
value: app!,
summary: summary,
policy,
path: newEditedPath || path,
draft_only: true,
custom_path: customPath
},
js,
css
}
})
}
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: savedApp.draft_only ? newEditedPath || path : path,
typ: 'app',
value: {
value: app!,
summary: summary,
policy,
path: newEditedPath || path
}
}
})
savedApp = {
...(savedApp?.draft_only
? {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: savedApp.draft_only ? newEditedPath || path : path,
policy,
draft_only: true,
custom_path: customPath
}
: savedApp),
draft: {
summary: summary,
value: structuredClone(stateSnapshot(app)),
path: newEditedPath || path,
policy,
custom_path: customPath
}
}
sendUserToast('Draft saved')
if (!inSessionPane) UserDraft.remove('raw_app', path)
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
dispatch('savedNewAppPath', newEditedPath || path)
}
} catch (e) {
loading.saveDraft = false
throw e
}
}
let onLatest = $state(true)
async function compareVersions() {
if (version === undefined) {
@@ -649,13 +483,6 @@
let moreItems = $derived([
...(compactTopbar
? [
{
displayName: 'Save draft',
icon: Save,
action: () => saveDraft(),
shortcut: `${mod}S`,
disabled: !newApp && !savedApp
},
{
displayName: `Jobs (${jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})`,
icon: Bug,
@@ -715,18 +542,6 @@
let jobsDrawerOpen = $state(false)
function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedApp,
modifiedValue: {
summary: summary,
value: app,
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
policy,
custom_path: customPath
}
}
}
let app = $derived(files ? { runnables: runnables, files, data } : undefined)
$effect(() => {
@@ -734,14 +549,6 @@
})
</script>
<!-- Inside a session pane the editor's content is continuously persisted to the
UserDraft (localStorage), so tearing the editor down on navigation loses
nothing — skip the unsaved-changes prompt. The standalone /apps_raw editor
keeps it. -->
{#if !inSessionPane}
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
{/if}
<DeployOverrideConfirmationModal
{deployedBy}
{confirmCallback}
@@ -751,46 +558,21 @@
currentValue={{
summary: summary,
value: app,
path: newEditedPath || savedApp?.draft?.path || savedApp?.path,
path: newEditedPath || savedApp?.path,
policy,
custom_path: customPath
}}
/>
{#if appPath == ''}
<Drawer bind:open={draftDrawerOpen} size="800px">
<DrawerContent title="Initial draft save" on:close={() => closeDraftDrawer()}>
{#snippet actions()}
<div>
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' || app == undefined}
on:click={() => saveInitialDraft()}
unifiedSize="md"
variant="accent"
>
Save initial draft
</Button>
</div>
{/snippet}
<AppEditorHeaderDeployInitialDraft
bind:summary
bind:appPath
bind:pathError
bind:newEditedPath
/>
</DrawerContent>
</Drawer>
{/if}
<Drawer bind:open={saveDrawerOpen} size="800px">
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
{#snippet actions()}
<div class="flex flex-row gap-2">
<Button
variant="default"
disabled={!savedApp || savedApp.draft_only}
disabled={!savedApp || newApp}
on:click={async () => {
if (!savedApp) {
if (!savedApp || newApp) {
return
}
// deployedValue should be syncronized when we open Diff
@@ -801,11 +583,10 @@
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
path: newEditedPath || savedApp.path,
policy,
custom_path: customPath
},
@@ -847,6 +628,7 @@
<AppEditorHeaderDeploy
{newPath}
{newApp}
{policy}
{setPublishState}
{appPath}
@@ -942,7 +724,15 @@
raw_app
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
/>
<div></div>
{#if $workspaceStore && liveEditorDraftStoragePath !== undefined}
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="raw_app"
path={liveEditorDraftStoragePath}
draftOnly={newApp}
{onResetToDeployed}
/>
{/if}
</div>
{#if $enterpriseLicense && appPath != ''}
@@ -965,9 +755,9 @@
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedApp}
disabled={!savedApp || newApp}
iconOnly={compactTopbar}
title="Diff"
title={newApp ? 'Deploy this app once to compare against the deployed version' : 'Diff'}
startIcon={{ icon: DiffIcon }}
>
Diff
@@ -1006,19 +796,6 @@
AI
</Button>
{/if}
{#if !compactTopbar}
<Button
loading={loading.save}
startIcon={{ icon: Save }}
on:click={() => saveDraft()}
unifiedSize="md"
variant="default"
disabled={!newApp && !savedApp}
shortCut={{ key: 'S' }}
>
Draft
</Button>
{/if}
<Button
loading={loading.save}
startIcon={{ icon: Save }}
@@ -0,0 +1,399 @@
<script lang="ts">
import { Sparkles, Plus, List, Ban, ExternalLinkIcon } from 'lucide-svelte'
import type { Policy } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import Modal from '$lib/components/common/modal/Modal.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { Alert } from '$lib/components/common'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
import { copilotInfo } from '$lib/aiStore'
import { react18Template, react19Template, svelte5Template } from './templates'
import type { Runnable } from './rawAppPolicy'
import { type DataTableRef, type RawAppData, formatDataTableRef } from './dataTableRefUtils'
import {
createDatatablesResource,
createSchemasResource,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
import RawAppDataTableList from './RawAppDataTableList.svelte'
import RawAppDataTableDrawer from './RawAppDataTableDrawer.svelte'
import FileEditorIcon from './FileEditorIcon.svelte'
export type RawAppTemplatePickerResult = {
files: Record<string, string>
runnables: Record<string, Runnable>
data: RawAppData
summary: string
policy: Policy
prompt?: string
}
let {
open = $bindable(false),
onStart
}: {
open?: boolean
onStart: (result: RawAppTemplatePickerResult, withPrompt: boolean) => void
} = $props()
const templates = [
{ name: 'React 19', icon: 'tsx', files: react19Template },
{ name: 'React 18', icon: 'tsx', files: react18Template },
{ name: 'Svelte 5', icon: 'svelte', files: svelte5Template }
]
let selectedTemplateIndex = $state(0)
let tableCreationEnabled = $state(true)
let selectedDatatable = $state<string | undefined>(undefined)
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
let selectedSchema = $state<string | undefined>(undefined)
let newSchemaName = $state('')
let appSummary = $state('')
let initialPrompt = $state('')
let preWhitelistedTables = $state<DataTableRef[]>([])
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
const datatables = createDatatablesResource(() => $workspaceStore)
const schemas = createSchemasResource(() => selectedDatatable)
const availableDatatables = $derived(datatables.current)
const availableSchemas = $derived(schemas.current)
let hasAutoSelected = false
$effect(() => {
if (availableDatatables?.length > 0 && !hasAutoSelected) {
hasAutoSelected = true
selectedDatatable = availableDatatables.includes('main') ? 'main' : availableDatatables[0]
}
})
function generateUniqueSchemaName(existingSchemas: string[]): string {
let num = 1
while (existingSchemas.includes(`app${num}`)) {
num++
}
return `app${num}`
}
const newSchemaAlreadyExists = $derived(
schemaMode === 'new' &&
newSchemaName.trim() !== '' &&
(availableSchemas ?? []).includes(newSchemaName.trim())
)
let userEditedSchemaName = $state(false)
$effect(() => {
const schemas = availableSchemas ?? []
if (schemaMode === 'new') {
if (!newSchemaName) {
newSchemaName = generateUniqueSchemaName(schemas)
userEditedSchemaName = false
} else if (!userEditedSchemaName && schemas.includes(newSchemaName)) {
newSchemaName = generateUniqueSchemaName(schemas)
}
}
})
const datatableItems = $derived(toDatatableItems(availableDatatables))
const schemaItems = $derived(toSchemaItems(availableSchemas))
const effectiveSchema = $derived(
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
)
const hasNoDatatables = $derived(availableDatatables?.length === 0)
const isAiEnabled = $derived($copilotInfo.enabled)
async function start(withPrompt: boolean) {
const template = templates[selectedTemplateIndex]
if (schemaMode === 'new' && newSchemaName && selectedDatatable && $workspaceStore) {
try {
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
const dbOps = dbSchemaOpsWithPreviewScripts({
workspace: $workspaceStore,
input: {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${selectedDatatable}`
}
})
await dbOps.onCreateSchema({ schema: newSchemaName })
} catch (e) {
console.error('Failed to create schema:', e)
sendUserToast(`Failed to create schema: ${e}`, true)
}
}
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
const data: RawAppData =
tableCreationEnabled && selectedDatatable
? {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema
}
: { tables: formattedTables, datatable: undefined, schema: undefined }
const policy: Policy = {
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
}
open = false
onStart(
{
files: template.files,
runnables: {},
data,
summary: appSummary.trim(),
policy,
prompt: withPrompt ? initialPrompt.trim() : undefined
},
withPrompt
)
}
</script>
{#if open}
<!-- `bind:open` (not `open`) so the inner Modal's X / Esc / click-
outside dismissal propagates back to the parent. Without it the
Modal closes its own UI but the picker's `open` prop stays true,
so the route's `templatePicker → false` watcher never fires and
autosave stays suspended after the dismissal. -->
<Modal kind="X" bind:open title="New App setup">
<div class="flex flex-col gap-6 min-w-sm">
<div>
<h2 class="text-xs font-semibold text-emphasis mb-1">Summary</h2>
<TextInput
bind:value={appSummary}
inputProps={{
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
}}
/>
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Framework</h2>
<div class="flex flex-wrap gap-3">
{#each templates as t, i}
<button
onclick={() => (selectedTemplateIndex = i)}
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
? 'bg-surface-accent-selected border border-accent'
: ''} hover:bg-surface-hover border rounded-lg transition-all"
>
<div class="w-full flex items-center justify-center">
<FileEditorIcon file={'.' + t.icon} size={32} />
</div>
<div class="center-center w-full text-sm text-secondary">{t.name}</div>
</button>
{/each}
</div>
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
{#if hasNoDatatables}
<Alert type="warning" title="No datatables configured.">
You can still create an app, but for data storage you won't be able to use data tables
which are <b>highly recommended</b>.
<br />
{#if $userStore?.is_admin}
Configure datatables in
<a
href="/workspace_settings?tab=windmill_data_tables"
target="_blank"
class="inline-flex items-center gap-1"
>workspace settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure datatables in workspace settings to enable this
feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary mb-1 block">Default settings for new tables</span>
<div class="flex flex-col gap-4 rounded-md p-4 border">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-emphasis font-semibold" for="datatable"
>Datatable</label
>
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
</div>
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
<div class="flex flex-row gap-1 w-full items-center">
<div>
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
<ToggleButton
value="existing"
label="Existing"
icon={List}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{#if schemaMode === 'new'}
<TextInput
bind:value={newSchemaName}
inputProps={{
placeholder: 'Schema name',
oninput: () => (userEditedSchemaName = true)
}}
class="flex-1"
error={newSchemaAlreadyExists}
size="sm"
/>
{:else if schemaMode === 'existing'}
<div class="flex-1">
<Select
disablePortal
items={schemaItems}
bind:value={selectedSchema}
placeholder="Schema"
size="sm"
/>
</div>
{/if}
</div>
{#if newSchemaAlreadyExists}
<span class="text-xs text-red-500"
>Schema "{newSchemaName}" already exists</span
>
{/if}
</div>
</div>
</div>
</div>
<div class="flex items-center">
<Toggle
size="sm"
bind:checked={tableCreationEnabled}
options={{ right: 'Allow AI to create new tables' }}
/>
</div>
<div class="pt-6">
<RawAppDataTableList
dataTableRefs={preWhitelistedTables}
defaultDatatable={selectedDatatable}
defaultSchema={effectiveSchema}
standalone
hideDefaultSelector
onAdd={() => dataTableDrawer?.openDrawer()}
onRemove={(index) => {
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
}}
/>
</div>
</div>
{/if}
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1 flex items-center gap-2">
<Sparkles size={16} class="text-ai" />
Start with AI
<span class="text-xs font-normal text-tertiary">(optional)</span>
</h2>
{#if !isAiEnabled}
<Alert type="info" title="AI is not configured for this workspace.">
You can still create an app manually but using AI is highly recommended.
<br />
{#if $userStore?.is_admin}
Configure AI in
<a
href="/workspace_settings?tab=ai"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>workspace settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure AI in workspace settings to enable this feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-2">
<TextInput
underlyingInputEl="textarea"
bind:value={initialPrompt}
inputProps={{
rows: 3,
placeholder:
"Describe what you want to build... (e.g., 'Create a todo list app with user authentication')"
}}
/>
<p class="text-xs text-tertiary">
Leave empty to start with a blank template, or describe your app to get AI assistance
right away.
</p>
</div>
{/if}
</div>
<div class="pt-6 flex justify-end gap-3">
<Button
variant="default"
size="sm"
on:click={() => start(false)}
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
>
Start without AI
</Button>
{#if isAiEnabled}
<Button
variant="accent"
on:click={() => start(true)}
disabled={!templates[selectedTemplateIndex] ||
!initialPrompt.trim() ||
newSchemaAlreadyExists}
startIcon={{ icon: Sparkles }}
btnClasses={AIBtnClasses('accent')}
>
Start with AI
</Button>
{/if}
</div>
</div>
</Modal>
{/if}
<RawAppDataTableDrawer
bind:this={dataTableDrawer}
offset={10000}
existingRefs={preWhitelistedTables}
onAdd={(ref) => {
preWhitelistedTables = [...preWhitelistedTables, ref]
}}
/>
+21 -7
View File
@@ -1,10 +1,10 @@
import type { NewScript } from '$lib/gen'
import type { NewScript, Script } from '$lib/gen'
import type { AssetWithAltAccessType } from './assets/lib'
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import type { DiffDrawerI } from './diff_drawer'
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
import type { ScheduleTrigger } from './triggers'
import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import type { Trigger } from './triggers/utils'
import type { WorkspaceItem } from './workspacePicker'
export interface ScriptBuilderProps {
@@ -15,6 +15,18 @@ export interface ScriptBuilderProps {
disableAi?: boolean
fullyLoaded?: boolean
initialPath?: string
/**
* Path the route's `UserDraft.use<EditableScript>('script', ...)`
* handle is keyed by. Distinct from `initialPath` for new drafts
* `initialPath` is the displayed/editor path (empty for new), while
* this is the URL path the draft is persisted under (`u/{user}/
* draft_{uuid}`). Used to bracket the bootstrap `initContent` write
* with `UserDraft.stopSync` / `restartSync` so the template seed
* doesn't POST before the user's first real edit. Default to `''`
* for backwards compat with callers that don't manage drafts; the
* stop/restart pair is a no-op on a non-live entry.
*/
userDraftPath?: string
template?:
| 'docker'
| 'bunnative'
@@ -29,7 +41,7 @@ export interface ScriptBuilderProps {
showMeta?: boolean
neverShowMeta?: boolean
diffDrawer?: DiffDrawerI | undefined
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
savedScript?: Script | NewScript | undefined
searchParams?: URLSearchParams
disableHistoryChange?: boolean
customUi?: ScriptBuilderWhitelabelCustomUi
@@ -41,12 +53,8 @@ export interface ScriptBuilderProps {
// the deployed item) — consumers should skip post-deploy navigation when set.
onDeploy?: (e: { path: string; hash: string; stay: boolean }) => void
onDeployError?: (e: { path: string; error: any }) => void
onSaveInitial?: (e: { path: string; hash: string }) => void
onHistoryRestore?: () => void
onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void
onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void
onSeeDetails?: (e: { path: string }) => void
onSaveDraftError?: (e: { path: string; error: any }) => void
onNavigate?: (item: WorkspaceItem) => void
// Forwarded to the underlying ScriptEditor. When true, the right-hand
// test/run pane opens collapsed. Used by the session preview.
@@ -56,4 +64,10 @@ export interface ScriptBuilderProps {
// overwrite it. Used by the session preview, which opens AI-created scripts
// as new but with a path the AI already assigned.
initialPathChosen?: boolean
// Threaded to the `AutosaveIndicator` popover so its "Reset to
// deployed" button can do the same thing the load-time toast offers.
// Routes pass their own re-load-without-draft callback here; omit on
// callers (session preview, embedded SDK) that shouldn't surface the
// action at all.
onResetToDeployed?: () => void | Promise<void>
}
@@ -14,7 +14,7 @@
unifiedSize="lg"
variant="accent"
startIcon={{ icon: Plus }}
href="{base}/scripts/add?nodraft=true"
href="{base}/scripts/add"
endIcon={{ icon: Code2 }}
>
Script
@@ -472,7 +472,6 @@
type?: U
time?: number
starred?: boolean
has_draft?: boolean
}
// interface SelectableSearchMenuItem {
@@ -50,7 +50,7 @@
}
// Mark this editor as the "live editor" for the session's workspace so
// the chat's `isLiveDraft` hint and `discard_local_draft` tool resolve to
// the chat's `isLiveDraft` hint and `discard_draft` tool resolve to
// this path. Same registration the regular /flows/edit page does on
// mount, scoped to the session's (forked) workspace.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
@@ -44,7 +44,7 @@
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// so the chat's `isLiveDraft` hint / `discard_draft` tool resolve
// to this path — same registration the regular /apps_raw/edit page does.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
@@ -4,8 +4,9 @@
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import { DraftService, ScriptService, type NewScript } from '$lib/gen'
import { ScriptService, type NewScript } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import SessionItemNotFound from './SessionItemNotFound.svelte'
import { sendUserToast } from '$lib/toast'
@@ -52,15 +53,26 @@
return
}
diffDrawer?.closeDrawer()
// Drop the backend (DB) draft too, so "deployed" sticks across a reload.
if (saved.draft) {
try {
await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path })
saved.draft = undefined
} catch (e: any) {
sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true)
return
}
// Drop the user's per-user draft too, so "deployed" sticks across
// a reload. The new overlay folds the draft into the response (no
// separate `.draft` field), so `saved.is_draft` is the signal that
// there's actually a draft worth deleting; the syncer's
// `value: null` POST is the canonical per-user delete.
//
// Fire-and-forget: every read here (`saved`, the snapshot we build
// below, the UserDraft.discard write) is purely in-memory, so we
// don't need the DELETE to have landed to finish the restore. We
// flip `is_draft` optimistically so the UI matches the new intent
// immediately. A failed DELETE only matters across a hard reload
// before it lands — log and move on.
if (saved.is_draft) {
saved.is_draft = false
UserDraftDbSyncer.save({
workspace: workspaceId,
itemKind: 'script',
path: saved.path,
value: null
}).catch((e) => console.error('restoreDeployed: draft delete failed', e))
}
const deployed = structuredClone($state.snapshot(saved)) as NewScript & { draft?: unknown }
delete deployed.draft
@@ -80,7 +92,7 @@
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// so the chat's `isLiveDraft` hint / `discard_draft` tool resolve
// to this path — same registration the regular /scripts/edit page does.
// Gated on `isActiveSession`: warm-but-hidden session editors must not
// claim the workspace's single live-editor slot, else chat actions on the
@@ -9,9 +9,28 @@ import {
WorkspaceService,
type Flow,
type NewScript,
type NewScriptWithDraft,
type Script,
type UserDraftOverlay,
type WorkspaceComparison
} from '$lib/gen'
// Carry the legacy `.draft` field through to consumers so this file
// doesn't need a deeper rewrite. The new `get_draft=true` overlay never
// populates it (the overlay already merges draft into the top-level
// fields), so `draft` is always `undefined` here — the
// `(saved.draft ?? saved)` fall-throughs downstream simply use the
// overlayed response, which is the right behavior. The overlay's
// `is_draft` / `draft_saved_at` ride alongside so downstream "is there
// a draft to delete?" checks have a typed handle on them.
// The generated `UserDraftOverlay` types `draft` as a permissive
// `{[key: string]: unknown}` because the backend doesn't constrain the
// draft shape per kind. Locally we know it's a `NewScript` / `Flow`
// (set by this editor's autosave), so we `Omit` the generic field and
// re-add it with the precise type. Assignments from the generated
// response type still need an explicit cast — the source has the wider
// `{[key: string]: unknown}` shape.
type SavedScript = Omit<Script & UserDraftOverlay, 'draft'> & { draft?: NewScript }
type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow }
import type { HiddenRunnable } from '$lib/components/apps/types'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { workspaceStore } from '$lib/stores'
@@ -45,14 +64,14 @@ export interface SessionRuntime {
// Flow target state
readonly flowStore: StateStore<Flow>
readonly flowStateStore: { val: Record<string, any> }
readonly savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined }
readonly savedFlow: { val: SavedFlow | undefined }
readonly loadingFlow: boolean
readonly notFound: boolean
readonly loadedPath: string | undefined
loadFlow(workspace: string, path: string, force?: boolean): Promise<void>
// Script target state (parallel to flow, populated only for script-targeted sessions)
readonly scriptStore: { val: NewScript | undefined }
readonly savedScript: { val: NewScriptWithDraft | undefined }
readonly savedScript: { val: SavedScript | undefined }
readonly loadingScript: boolean
readonly notFoundScript: boolean
readonly loadedScriptPath: string | undefined
@@ -247,7 +266,7 @@ function createRuntime(session: Session): SessionRuntime {
const flowStore: StateStore<Flow> = $state({ val: emptyFlow() })
const flowStateStore: { val: Record<string, any> } = $state({ val: {} })
const savedFlow: { val: (Flow & { draft?: Flow | undefined }) | undefined } = $state({
const savedFlow: { val: SavedFlow | undefined } = $state({
val: undefined
})
@@ -256,7 +275,7 @@ function createRuntime(session: Session): SessionRuntime {
let loadedPath = $state<string | undefined>(undefined)
const scriptStore: { val: NewScript | undefined } = $state({ val: undefined })
const savedScript: { val: NewScriptWithDraft | undefined } = $state({ val: undefined })
const savedScript: { val: SavedScript | undefined } = $state({ val: undefined })
let loadingScript = $state(false)
let notFoundScript = $state(false)
let loadedScriptPath = $state<string | undefined>(undefined)
@@ -340,8 +359,8 @@ function createRuntime(session: Session): SessionRuntime {
// drawer. Don't fail the load if the path doesn't exist
// yet on the backend — draft-only flows are a valid state.
try {
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
savedFlow.val = result
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
savedFlow.val = result as SavedFlow
} catch {
savedFlow.val = undefined
}
@@ -352,11 +371,14 @@ function createRuntime(session: Session): SessionRuntime {
return
}
// No draft yet. Seed one from the last deploy (or the
// backend-side draft, if one exists).
const result = await FlowService.getFlowByPathWithDraft({ workspace, path })
savedFlow.val = result
const flow: Flow = (result.draft as Flow | undefined) ?? (result as Flow)
// No draft yet. Seed one from the last deploy — or from the
// backend-side draft, if one exists. The `get_draft=true`
// response attaches the user's saved draft as `.draft`
// (deployed stays untouched in the response body), so the
// seed is `result.draft ?? result`.
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
savedFlow.val = result as SavedFlow
const flow: Flow = ((result as SavedFlow).draft ?? (result as Flow)) as Flow
UserDraft.save('flow', path, flow, { workspace })
await initFlow(flow, flowStore, flowStateStore)
if (deployedVersionId != null && flowStore.val) flowStore.val.version_id = deployedVersionId
@@ -402,8 +424,8 @@ function createRuntime(session: Session): SessionRuntime {
// drawer + parent_hash. 404 means draft-only — leave
// savedScript undefined and skip parent_hash.
try {
const result = await ScriptService.getScriptByPathWithDraft({ workspace, path })
savedScript.val = result
const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
savedScript.val = result as SavedScript
} catch {
savedScript.val = undefined
}
@@ -437,14 +459,17 @@ function createRuntime(session: Session): SessionRuntime {
return
}
// No draft yet. Seed from backend.
const result = await ScriptService.getScriptByPathWithDraft({ workspace, path })
savedScript.val = result
// Clone before mutating: when result.draft is falsy, `baseline` would
// otherwise alias `result` (= savedScript.val), so baseline.parent_hash
// would corrupt the pristine deployed baseline the diff drawer reads.
// No draft yet. Seed from backend. `get_draft=true` returns
// the deployed in the response body plus the user's saved
// draft (if any) as `.draft` — seed from `.draft` when
// present, else from the deployed.
const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
savedScript.val = result as SavedScript
// Clone before mutating: otherwise `baseline` would alias
// `result` (= savedScript.val), so `baseline.parent_hash` would
// corrupt the pristine baseline the diff drawer reads.
const baseline = structuredClone(
(result.draft as NewScript | undefined) ?? (result as NewScript)
((result as SavedScript).draft as NewScript | undefined) ?? (result as NewScript)
)
baseline.parent_hash = result.hash
UserDraft.save<NewScript>('script', path, baseline, { workspace })
@@ -489,14 +514,23 @@ function createRuntime(session: Session): SessionRuntime {
// drawer. Don't fail the load if the path doesn't exist
// yet on the backend — draft-only apps are a valid state.
try {
const result = await AppService.getAppByPathWithDraft({ workspace, path })
const result = await AppService.getAppByPath({
workspace,
path,
getDraft: true,
rawApp: true
})
// `get_draft=true` overlays the user's draft into the
// top-level fields, so there's no separate `.draft`
// pocket on the response anymore. Leave `draft` /
// `draft_only` `undefined` here — the consumer fall-
// throughs (`saved.draft ?? saved`) just use the
// already-overlayed response.
savedRawApp.val = {
summary: result.summary,
value: result.value as any,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft: result.draft,
custom_path: result.custom_path
}
} catch {
@@ -521,17 +555,22 @@ function createRuntime(session: Session): SessionRuntime {
// backend-side draft, if one exists — that's the user's
// "Save draft" content from the standalone editor and is
// fresher than `value`).
const result = await AppService.getAppByPathWithDraft({ workspace, path })
const result = await AppService.getAppByPath({
workspace,
path,
getDraft: true,
rawApp: true
})
// See above: no separate `.draft` field on the new overlay
// response. The merged value lives directly under `.value`.
savedRawApp.val = {
summary: result.summary,
value: result.value as any,
path: result.path,
policy: result.policy,
draft_only: result.draft_only,
draft: result.draft,
custom_path: result.custom_path
}
const sourceValue: any = result.draft ?? result.value
const sourceValue: any = result.value
let data: RawAppData = { ...DEFAULT_DATA }
if (sourceValue?.data) {
const d = sourceValue.data
@@ -135,11 +135,12 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultValues)
const draftOverlay = await loadTrigger(defaultValues)
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getAzureConfig()))
}
originalConfig = structuredClone($state.snapshot(getAzureConfig()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load Azure trigger: ${err.body}`, true)
@@ -184,19 +185,28 @@
}
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
return undefined
}
try {
const s = await AzureTriggerService.getAzureTrigger({
workspace: $workspaceStore!,
path: initialPath
path: initialPath,
getDraft: true
})
loadTriggerConfig(s)
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (error) {
sendUserToast(`Could not load Azure trigger: ${error.body}`, true)
return undefined
}
}
@@ -131,12 +131,16 @@
edit = true
dirtyPath = false
dirtyLocalPart = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
// Form holds DEPLOYED here. Capture `originalConfig` as the
// deployed baseline so `hasChanged` (= current != originalConfig)
// fires whenever a draft exists, not only after the user edits.
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) as NewEmailTrigger
if (draftOverlay) loadTriggerConfig(draftOverlay as Partial<EmailTrigger>)
if (!defaultConfig) {
// If the email trigger is loaded from the backend, we to set the initial config
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
initialConfig = structuredClone($state.snapshot(getEmailTriggerConfig())) as NewEmailTrigger
}
originalConfig = structuredClone($state.snapshot(getEmailTriggerConfig()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load email trigger: ${err}`, true)
@@ -206,18 +210,29 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
async function loadTrigger(defaultConfig?: Partial<EmailTrigger>): Promise<void> {
/**
* Apply the deployed config to the form, then return the saved-draft
* overlay (if any) so the caller can capture the deployed-only form
* state as `originalConfig` BEFORE applying the draft. See
* `NatsTriggerEditorInner` for the rationale.
*/
async function loadTrigger(
defaultConfig?: Partial<EmailTrigger>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await EmailTriggerService.getEmailTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
return undefined
}
const s = await EmailTriggerService.getEmailTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
async function triggerScript(): Promise<void> {
@@ -137,11 +137,12 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultValues)
const draftOverlay = await loadTrigger(defaultValues)
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getGcpConfig()))
}
originalConfig = structuredClone($state.snapshot(getGcpConfig()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true)
@@ -188,20 +189,28 @@
}
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
try {
const s = await GcpTriggerService.getGcpTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
} catch (error) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true)
}
return undefined
}
try {
const s = await GcpTriggerService.getGcpTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (error) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true)
return undefined
}
}
@@ -230,12 +230,13 @@
edit = true
dirtyPath = false
dirtyRoutePath = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getRouteConfig())) as NewHttpTrigger
if (draftOverlay) loadTriggerConfig(draftOverlay as Partial<HttpTrigger>)
if (!defaultConfig) {
// If the route is loaded from the backend, we to set the initial config
initialConfig = structuredClone($state.snapshot(getRouteConfig()))
initialConfig = structuredClone($state.snapshot(getRouteConfig())) as NewHttpTrigger
}
originalConfig = structuredClone($state.snapshot(getRouteConfig()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load route: ${err}`, true)
@@ -339,18 +340,24 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
async function loadTrigger(defaultConfig?: Partial<HttpTrigger>): Promise<void> {
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Partial<HttpTrigger>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await HttpTriggerService.getHttpTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
return undefined
}
const s = await HttpTriggerService.getHttpTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
async function triggerScript(): Promise<void> {
@@ -159,11 +159,12 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load Kafka trigger: ${err}`, true)
@@ -245,17 +246,24 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await KafkaTriggerService.getKafkaTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
return undefined
}
const s = await KafkaTriggerService.getKafkaTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
function getSaveCfg(): Record<string, any> {
@@ -155,11 +155,12 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load mqtt trigger: ${err.body}`, true)
@@ -244,20 +245,28 @@
}
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
try {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await MqttTriggerService.getMqttTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
return undefined
}
const s = await MqttTriggerService.getMqttTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (error) {
sendUserToast(`Could not load mqtt trigger: ${error.body}`, true)
return undefined
}
}
@@ -143,11 +143,18 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
// At this point the form holds the DEPLOYED config (or
// `defaultConfig` for new triggers). Capture `originalConfig`
// here so `hasChanged` (= `current != originalConfig`) compares
// against the deployed baseline; if a draft exists, applying
// the overlay below makes `current != originalConfig` fire
// the "unsaved changes" banner immediately.
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load nats trigger: ${err}`, true)
@@ -227,17 +234,32 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
/**
* Apply the deployed config to the form, then return the saved-draft
* overlay (if any) so the caller can capture the deployed-only form
* state as `originalConfig` BEFORE applying the draft. The
* "unsaved changes" banner compares `current` vs `originalConfig`,
* so capturing originalConfig from the deployed-only form makes the
* banner fire whenever a draft is present (instead of only after
* the user starts editing on top of the draft).
*/
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await NatsTriggerService.getNatsTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
return undefined
}
const s = await NatsTriggerService.getNatsTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
function getSaveCfg() {
@@ -250,11 +250,12 @@
relations = []
transaction_to_track = []
tab = 'basic'
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load postgres trigger: ${err.body}`, true)
@@ -367,28 +368,62 @@
preservePermissionedAs = !!cfg?.permissioned_as
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
/**
* Apply the deployed config to the form (incl. publication fetch),
* then return the saved-draft overlay (already merged with the
* publication payload) so the caller can capture the deployed-only
* form state as `originalConfig` BEFORE applying the draft. See
* `NatsTriggerEditorInner.loadTrigger` for the broader rationale.
*
* Postgres-specific wrinkle: the publication payload is fetched from
* the resource — keyed by `postgres_resource_path` /
* `publication_name`, which the draft may have changed. Fetch once
* using the effective values so the overlay reflects the draft's
* publication, not the deployed one.
*/
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
if (defaultConfig?.publication) {
transaction_to_track = [...defaultConfig.publication.transaction_to_track]
relations = defaultConfig.publication.table_to_track ?? []
}
return
} else {
const s = await PostgresTriggerService.getPostgresTrigger({
workspace: $workspaceStore!,
path: initialPath
})
const publication_data = await PostgresTriggerService.getPostgresPublication({
path: s.postgres_resource_path,
workspace: $workspaceStore!,
publication: s.publication_name
})
loadTriggerConfig({ ...s, publication: publication_data })
return undefined
}
const s = await PostgresTriggerService.getPostgresTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
// Fetch deployed publication and apply deployed config — this
// becomes the `originalConfig` baseline for the dirty check.
const deployedPublication = await PostgresTriggerService.getPostgresPublication({
path: deployedTrigger.postgres_resource_path,
workspace: $workspaceStore!,
publication: deployedTrigger.publication_name
})
loadTriggerConfig({ ...deployedTrigger, publication: deployedPublication })
if (!draftFromBackend) return undefined
// Draft may have changed the resource/publication keys; fetch
// the publication that matches the effective values so the
// overlay opens on the draft's view.
const effective = { ...deployedTrigger, ...draftFromBackend }
const effectivePublication =
effective.postgres_resource_path === deployedTrigger.postgres_resource_path &&
effective.publication_name === deployedTrigger.publication_name
? deployedPublication
: await PostgresTriggerService.getPostgresPublication({
path: effective.postgres_resource_path,
workspace: $workspaceStore!,
publication: effective.publication_name
})
return { ...effective, publication: effectivePublication }
}
function getCaptureConfig() {
@@ -152,11 +152,15 @@
initialPath = ePath
itemKind = isFlow ? 'flow' : 'script'
path = defaultCfg?.path ?? ePath
await loadSchedule(defaultCfg)
const draftOverlay = await loadSchedule(defaultCfg)
edit = true
if (!defaultCfg) {
// Form holds DEPLOYED here. Capture `initialConfig` as the
// deployed baseline so the dirty check / unsaved banner
// fires whenever a saved draft exists.
initialConfig = structuredClone($state.snapshot(getScheduleCfg()))
}
if (draftOverlay) await loadScheduleCfg(draftOverlay)
await draftSync.maybeRestore()
} finally {
clearTimeout(loadingTimeout)
@@ -277,8 +281,10 @@
nis_flow: boolean,
initial_script_path?: string,
defaultValues?: Schedule,
schedule_path?: string
schedule_path?: string,
opts: { getDraft?: boolean } = {}
) {
const getDraft = opts.getDraft ?? true
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -286,10 +292,19 @@
try {
let s: Schedule | undefined
if (schedule_path) {
s = await ScheduleService.getSchedule({
const resp = await ScheduleService.getSchedule({
workspace: $workspaceStore!,
path: schedule_path
path: schedule_path,
getDraft
})
// The autosaved draft (when present) sits in `.draft` as the
// editor's saved Schedule shape. Layer it over the deployed
// at the field level so the inline form assignments below
// see the editor's last-saved state.
const { draft: draftFromBackend, ...deployedSchedule } = resp as any
s = draftFromBackend
? ({ ...deployedSchedule, ...draftFromBackend } as Schedule)
: (deployedSchedule as Schedule)
initNewPath = true
} else if (defaultValues) {
s = defaultValues
@@ -452,19 +467,36 @@
}
}
async function loadSchedule(defaultCfg?: Record<string, any>): Promise<void> {
if (!defaultCfg) {
try {
const s = await ScheduleService.getSchedule({
workspace: $workspaceStore!,
path: initialPath
})
await loadScheduleCfg(s)
} catch (err) {
sendUserToast(`Could not load schedule: ${err}`, true)
}
} else {
/**
* Apply the deployed schedule config to the form, then return the
* saved-draft overlay (if any) so the caller can capture the
* deployed-only form state as `initialConfig` BEFORE applying the
* draft. The "unsaved changes" banner compares `current` vs
* `initialConfig` (via `useTriggerDraftSync.deployed`), so capturing
* from the deployed-only form makes the banner fire whenever a
* draft is present.
*/
async function loadSchedule(
defaultCfg?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultCfg) {
await loadScheduleCfg(defaultCfg)
return undefined
}
try {
const s = await ScheduleService.getSchedule({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedSchedule } = s as any
await loadScheduleCfg(deployedSchedule)
return draftFromBackend
? ({ ...deployedSchedule, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (err) {
sendUserToast(`Could not load schedule: ${err}`, true)
return undefined
}
}
@@ -138,14 +138,16 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger(defaultConfig)
// Snapshot the *backend* config as the baseline before overlaying
// any local autosave, so hasChanged / onConfigChange correctly
// flag the local edits as unsaved changes.
const draftOverlay = await loadTrigger(defaultConfig)
// Snapshot the *deployed* config as the baseline before
// overlaying the saved draft, so hasChanged compares
// draft-vs-deployed and the banner fires whenever a draft
// exists.
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load sqs trigger: ${err.body}`, true)
@@ -221,20 +223,28 @@
}
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
try {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await SqsTriggerService.getSqsTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
return undefined
}
const s = await SqsTriggerService.getSqsTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
} catch (error) {
sendUserToast(`Could not load SQS trigger: ${error.body}`, true)
return undefined
}
}
+2 -65
View File
@@ -650,71 +650,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] {
})
}
export type FlowWithDraftAndDraftTriggers = Flow & {
draft?: Flow & {
draft_triggers?: Trigger[]
}
}
export type NewScriptWithDraftAndDraftTriggers = NewScript & {
draft?: NewScript & { draft_triggers?: Trigger[] }
hash: string
}
// Get rid of deployed triggers from the saved flow in the case there is a match with a deployed trigger
export function filterDraftTriggers(
savedValue: FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers,
triggersState: Triggers
): FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers {
const deployedTriggers = triggersState.triggers.filter((t) => !t.draftConfig && !t.isDraft)
// Early return if no deployed triggers or no draft triggers to filter
if (deployedTriggers.length === 0 || !savedValue?.draft?.draft_triggers?.length) {
return savedValue
}
const deployedTriggerKeys = new Set(deployedTriggers.map((t) => `${t.path}:${t.type}`))
const originalSavedDraftTriggers = savedValue.draft.draft_triggers
const keptTriggers: Trigger[] = []
const removedTriggers: Trigger[] = []
// Single pass to separate kept vs removed triggers
for (const savedTrigger of originalSavedDraftTriggers) {
const triggerKey = `${savedTrigger.draftConfig?.path}:${savedTrigger.type}`
if (deployedTriggerKeys.has(triggerKey)) {
removedTriggers.push(savedTrigger)
} else {
keptTriggers.push(savedTrigger)
}
}
// Early return if nothing was filtered
if (removedTriggers.length === 0) {
return savedValue
}
// Update saved value
const newSavedValue = {
...savedValue,
draft: {
...savedValue.draft,
draft_triggers: keptTriggers.length > 0 ? keptTriggers : undefined
}
} as typeof savedValue
const removedTriggerKeys = new Set(removedTriggers.map((t) => `${t.draftConfig?.path}:${t.type}`))
// Remove filtered triggers from triggersState
triggersState.setTriggers(
triggersState.triggers.filter((trigger) => {
const triggerKey = `${trigger.draftConfig?.path}:${trigger.type}`
return !removedTriggerKeys.has(triggerKey)
})
)
return newSavedValue
}
export type FlowWithDraftAndDraftTriggers = Flow
export type NewScriptWithDraftAndDraftTriggers = NewScript & { hash?: string }
export function getHandlerType(scriptPath: string): ErrorHandler {
const handlerMap = {
@@ -187,11 +187,12 @@
edit = true
dirtyPath = false
dirtyUrl = false
await loadTrigger(defaultConfig)
const draftOverlay = await loadTrigger(defaultConfig)
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
if (draftOverlay) loadTriggerConfig(draftOverlay)
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
originalConfig = structuredClone($state.snapshot(getSaveCfg()))
await draftSync.maybeRestore()
} catch (err) {
sendUserToast(`Could not load websocket trigger: ${err}`, true)
@@ -302,17 +303,24 @@
}
}
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
/** See `NatsTriggerEditorInner.loadTrigger` for the rationale. */
async function loadTrigger(
defaultConfig?: Record<string, any>
): Promise<Record<string, any> | undefined> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await WebsocketTriggerService.getWebsocketTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
return undefined
}
const s = await WebsocketTriggerService.getWebsocketTrigger({
workspace: $workspaceStore!,
path: initialPath,
getDraft: true
})
const { draft: draftFromBackend, ...deployedTrigger } = (s ?? {}) as any
loadTriggerConfig(deployedTrigger)
return draftFromBackend
? ({ ...deployedTrigger, ...draftFromBackend } as Record<string, any>)
: undefined
}
let initialMessageRunnableSchemas: Record<string, Schema> = $state({})
+104
View File
@@ -0,0 +1,104 @@
/**
* Per-key debouncer with a max-wait ceiling lodash's
* `debounce(fn, { wait, maxWait })` but the dispatch is keyed and each
* `schedule` call carries its own replacement task.
*
* Within a "chain" (the run of pending schedules ending at the next
* fire) the latest `fn` wins and the timer is pushed `debounceMs` into
* the future. The push is capped so the chain can never sit longer
* than `maxDebounceMs` from when it started a constant trickle of
* fast writes can't delay a save forever.
*
* When the timer fires the chain ends; the next `schedule` starts a
* fresh chain with its own `chainStart`. Keys are independent.
*
* Errors from sync throws and from async rejections are logged and
* swallowed so a bad task doesn't break future scheduling.
*/
import { SvelteSet } from 'svelte/reactivity'
export type DebouncedTask = () => unknown | Promise<unknown>
export type DebouncerByKey = {
/** Replace any pending task under `key` with `fn`, set/extend the
* timer to `min(now + debounceMs, chainStart + maxDebounceMs)`. */
schedule(key: string, fn: DebouncedTask): void
/** Clear the timer and drop the pending task for `key` without
* running it. Returns true if there was something to cancel. Use
* to hand control of a key over to an imperative path (e.g. an
* immediate save that supersedes the queued autosave). */
cancel(key: string): boolean
/** Reactively whether `key` currently has a queued (not-yet-fired)
* task. Backed by a `SvelteSet`, so reading this inside a `$derived`
* / `$effect` re-runs when the key's pending state flips. */
isPending(key: string): boolean
}
type Entry = {
timer: ReturnType<typeof setTimeout>
task: DebouncedTask
/** Wall-clock ms when the current chain's first schedule landed.
* The max-wait ceiling is measured from here, not from "now". */
chainStart: number
}
export function createDebouncerByKey(opts: {
debounceMs: number
maxDebounceMs: number
}): DebouncerByKey {
const { debounceMs, maxDebounceMs } = opts
const entries = new Map<string, Entry>()
// Reactive mirror of `entries`' key set. Updated in lock-step with
// `entries` so `isPending` can be read from a reactive context. A
// `SvelteSet` (not a plain `$state` field) gives per-key subscriptions
// — readers only re-run when their own key flips.
const pendingKeys = new SvelteSet<string>()
function fire(key: string): void {
const entry = entries.get(key)
if (!entry) return
entries.delete(key)
// Drop from `pendingKeys` before running the task: the task hands
// off to the coalescing runner, which flips the key to "running" in
// the same synchronous tick, so there's no observable gap to "none".
pendingKeys.delete(key)
try {
const result = entry.task()
if (result && typeof (result as Promise<unknown>).then === 'function') {
;(result as Promise<unknown>).catch((e) =>
console.error('debouncerByKey: task rejected', e)
)
}
} catch (e) {
console.error('debouncerByKey: task threw', e)
}
}
function schedule(key: string, fn: DebouncedTask): void {
const now = Date.now()
const existing = entries.get(key)
const chainStart = existing?.chainStart ?? now
const fireAt = Math.min(now + debounceMs, chainStart + maxDebounceMs)
const delay = Math.max(0, fireAt - now)
if (existing) clearTimeout(existing.timer)
const timer = setTimeout(() => fire(key), delay)
entries.set(key, { timer, task: fn, chainStart })
pendingKeys.add(key)
}
function cancel(key: string): boolean {
const existing = entries.get(key)
if (!existing) return false
clearTimeout(existing.timer)
entries.delete(key)
pendingKeys.delete(key)
return true
}
function isPending(key: string): boolean {
return pendingKeys.has(key)
}
return { schedule, cancel, isPending }
}
+23 -11
View File
@@ -600,11 +600,21 @@ export function useLocalStorageValue<T>(
* across long editing sessions.
*/
transformBeforePersist?: (val: T) => T
/**
* Register a `$effect` that walks the stored value on every access and
* persists when any nested field mutated. Required for callers that
* mutate the value in place (`s.foo = bar`) rather than reassigning
* via the setter. Setter-only callers (e.g. a flat string slot) should
* leave this `false` the `$effect` requires a Svelte effect scope,
* so enabling it forces the hook to be called from inside a component.
*/
reactToNestedUpdates?: boolean
}
): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } {
const saveInitialValue = options?.saveInitialValue ?? true
const debounceMs = options?.debounce ?? 0
const transformBeforePersist = options?.transformBeforePersist
const reactToNestedUpdates = options?.reactToNestedUpdates ?? false
const serialize = (val: T) =>
typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val)
const deserialize = (val: string): T => {
@@ -672,17 +682,19 @@ export function useLocalStorageValue<T>(
pendingValue = undefined
}
$effect(() => {
readFieldsRecursively(s)
const next = s === undefined ? undefined : serialize(s)
if (next === lastSerialized) return
lastSerialized = next
if (skipNextWrite) {
skipNextWrite = false
return
}
schedulePersist(s)
})
if (reactToNestedUpdates) {
$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() {
+4 -4
View File
@@ -67,7 +67,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
title: 'Build a flow',
description: 'Learn how to build workflows in Windmill with our interactive tutorial.',
onClick: () => {
window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial&nodraft=true`
window.location.href = `${base}/flows/add?tutorial=flow-live-tutorial`
},
index: 2,
active: true,
@@ -81,7 +81,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
title: 'Fix a broken flow',
description: 'Learn how to monitor and debug your script and flow executions.',
onClick: () => {
window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow&nodraft=true`
window.location.href = `${base}/flows/add?tutorial=troubleshoot-flow`
},
index: 3,
active: true,
@@ -131,7 +131,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
title: 'Background runnables',
description: 'Learn how to create and use background runnables in your apps.',
onClick: () => {
window.location.href = `${base}/apps/add?tutorial=backgroundrunnables&nodraft=true`
window.location.href = `${base}/apps/add?tutorial=backgroundrunnables`
},
index: 4,
active: true,
@@ -145,7 +145,7 @@ export const TUTORIALS_CONFIG: Record<TabId, TabConfig> = {
title: 'Connection',
description: 'Learn how to connect component inputs to outputs in your apps.',
onClick: () => {
window.location.href = `${base}/apps/add?tutorial=connection&nodraft=true`
window.location.href = `${base}/apps/add?tutorial=connection`
},
index: 5,
active: true,
+345 -386
View File
@@ -2,8 +2,16 @@ import { get } from 'svelte/store'
import { onDestroy, untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import { workspaceStore } from './stores'
import { useLocalStorageValue } from './svelte5Utils.svelte'
import { readFieldsRecursively } from './utils'
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
import type { UserDraftItemKind } from './gen'
export type { UserDraftItemKind }
// Runtime array mirroring the generated `UserDraftItemKind` union. The
// `satisfies` clause makes the compiler reject this file if the two
// drift — adding a kind to the OpenAPI schema without listing it here
// (or vice-versa) is a type error.
export const USER_DRAFT_ITEM_KINDS = [
'script',
'flow',
@@ -29,48 +37,55 @@ export const USER_DRAFT_ITEM_KINDS = [
'trigger_nextcloud',
'trigger_google',
'trigger_github'
] as const
] as const satisfies readonly UserDraftItemKind[]
export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number]
// And the reverse direction: every member of the generated union must
// appear in `USER_DRAFT_ITEM_KINDS`.
type _AssertKindsExhaustive =
Exclude<UserDraftItemKind, (typeof USER_DRAFT_ITEM_KINDS)[number]> extends never ? true : never
const _: _AssertKindsExhaustive = true
void _
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?: readonly UserDraftItemKind[]
}
/**
* A single (kind, path, workspace) tuple that `useMany` should hold a handle
* for. The shape mirrors `use()`'s arguments, just bundled into one object
* so a getter can return a list of them.
* 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> = {
export type UserDraftSpec<V = unknown> = {
itemKind: UserDraftItemKind
path: string
workspace?: string
/**
* Value the handle reports when the entry is first acquired and no
* autosave is persisted. Seeded into the in-memory cell on acquire and
* swallowed by the sync effect so it never POSTs the user's first
* real edit is the first synced write. An entry that already exists
* (refcount > 0, e.g. another live handle) keeps its current value; the
* default is ignored in that case (an existing autosave always wins).
*/
defaultValue?: V
}
/**
* Snapshot of the remote item's freshness at the moment the local draft was
* 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.
* Snapshot of the remote item's freshness at the moment the local draft
* was seeded. Used by editor routes to detect that the remote has moved
* on since the user last saw it.
*
* - `remoteRev`: the deployed version's id/hash/timestamp at draft creation.
* - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set
* - `remoteRev`: the deployed version's id/hash/timestamp at draft load.
* - `remoteDraftRev`: the DB-draft `created_at` at draft load, only set
* for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`).
*
* Meta is in-memory only. It's seeded by the editor on every load (from
* the backend response) and lost on page reload which is fine because
* the editor reseeds it.
*/
export type UserDraftMeta = {
remoteRev?: string | number
@@ -78,29 +93,14 @@ export type UserDraftMeta = {
}
/**
* 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.
* In-memory cell shape. The value + meta are wrapped together so a single
* reactive assignment carries both, which keeps the DB sync effect's
* "did anything change" comparison stable.
*/
type StoredDraft<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
if (stored === undefined) return undefined
return { ...stored, lastWrittenAt: Date.now() }
}
type StoredDraft<V> = { value: V } & UserDraftMeta
type DraftState<V> = {
val: StoredDraft<V> | undefined
skipNextWriteOnce(): void
setWithoutPersist(newVal: StoredDraft<V> | undefined): void
}
type DraftEntry = {
@@ -110,12 +110,27 @@ type DraftEntry = {
path: string
state: DraftState<unknown>
/**
* Tears down the `$effect.root` scope that owns the entry's
* `useLocalStorageValue` reactivity its `$state` cell and the persist
* `$effect` deep-mutation loop. Called when the refcount hits 0.
*
* `undefined` only when the test runtime's broken `$effect.root` forced
* us through the fallback path (see `acquireEntry`).
* Single-shot flag consumed by the reactive sync effect in
* `acquireEntry`. Callers that already pushed the right thing to the
* server (e.g. `discard` explicit `value: null` POST) set this
* before the reactive write so the effect doesn't fire a second,
* incorrect POST.
*/
skipNextSync: boolean
/**
* Sticky version of `skipNextSync`. While true, the reactive sync
* effect updates the local state but never POSTs used by callers
* that programmatically mutate the draft as part of bootstrapping
* (e.g. setting the editor's `initialCode` after mount) and don't
* want those writes to land on the server as the user's "first
* autosave". Toggled via `UserDraft.stopSync` / `restartSync`.
*/
syncSuspended: boolean
/**
* Tears down the `$effect.root` scope that owns the entry's sync
* effect. 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
}
@@ -126,8 +141,6 @@ export type UserDraftEntry<V = unknown> = {
path: string
value: V | undefined
meta: UserDraftMeta
persisted: boolean
live: boolean
}
export type LiveEditorDraft = {
@@ -150,6 +163,14 @@ export type ClearLiveEditorDraftOptions = UserDraftOptions & {
const entries = new Map<string, DraftEntry>()
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
/**
* Map keys (`workspace|kind|path`) that should start `syncSuspended`
* when their entry is acquired. Lets callers `stopSync` BEFORE an
* editor has mounted (and called `UserDraft.use`) for routes that
* suspend the bootstrap save before triggering ScriptBuilder/AppEditor
* to mount. Consumed by `acquireEntry`; the matching `restartSync`
* (or a second `stopSync` on the live entry) clears it normally. */
const pendingSuspensions = new Set<string>()
function resolveWorkspace(opts?: UserDraftOptions): string {
const ws = opts?.workspace ?? get(workspaceStore)
@@ -185,15 +206,6 @@ function extractMeta(stored: StoredDraft<unknown> | undefined): UserDraftMeta {
* 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'
@@ -210,66 +222,14 @@ export function checkStaleness(
return null
}
/**
* Synchronous localStorage write, bypassing the entry's debounced setter
* and its first-write skip. See `setMeta({ force: true })`.
*/
function persistDirect<V>(key: string, value: V | undefined, meta: UserDraftMeta): void {
try {
const next = stamp(wrap(value, meta))
if (next === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(next))
}
} catch (e) {
console.error('UserDraft: localStorage write failed', e)
}
}
function readPersisted<V>(key: string): StoredDraft<V> | undefined {
try {
const raw = localStorage.getItem(key)
if (raw == null || raw === 'undefined') return undefined
const parsed = JSON.parse(raw)
// Defensive: ignore pre-wrapping payloads (no `.value`).
if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined
return parsed as StoredDraft<V>
} catch (e) {
console.error('UserDraft: localStorage read failed', e)
return undefined
}
}
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `${workspace}/${itemKind}/${path}`
}
function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `userdraft/w/${workspace}/${itemKind}/${path}`
}
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
return `${workspace}/${itemKind}`
}
function parseLocalStorageKey(
key: string,
workspace: string,
itemKinds: readonly UserDraftItemKind[]
): { itemKind: UserDraftItemKind; path: string } | undefined {
const prefix = `userdraft/w/${workspace}/`
if (!key.startsWith(prefix)) return undefined
const rest = key.slice(prefix.length)
for (const itemKind of itemKinds) {
const kindPrefix = `${itemKind}/`
if (rest.startsWith(kindPrefix)) {
return { itemKind, path: rest.slice(kindPrefix.length) }
}
}
return undefined
}
function snapshotDraftValue<V>(value: V | undefined): V | undefined {
if (value === undefined) return undefined
try {
@@ -287,33 +247,29 @@ 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.
* 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.
* Set value AND rev metadata in one write. Later `draft = X` writes
* preserve the rev metadata.
*/
setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void
/**
* Update rev metadata without touching the value. `{ force: true }` also
* persists synchronously use when this may be the entry's first write,
* else the ack is lost on remount.
* Update rev metadata without touching the value. The `{ force }`
* option is preserved for source compatibility but is now a no-op
* (there is no localStorage layer to write synchronously through).
*/
setMeta(meta: UserDraftMeta, opts?: { force?: boolean }): void
}
/**
* JSON round-trip normalization. localStorage persistence stringifies the
* draft, which silently drops keys whose value is `undefined`, turns `Date`
* into a string, etc. A freshly-built config object (e.g. a trigger editor's
* `getXConfig()`) keeps those `undefined`-valued keys, so a raw
* `deepEqual(persistedDraft, freshConfig)` reports spurious differences
* (`{ a: undefined }` `{}`). Normalize BOTH sides through the same
* round-trip before comparing. Returns the input unchanged if it can't be
* serialized (e.g. a cyclic structure) better a false "differs" than a
* throw inside a load/effect path.
* JSON round-trip normalization. Freshly-built config objects (e.g. a
* trigger editor's `getXConfig()`) keep `undefined`-valued keys, so a
* raw `deepEqual` reports spurious differences (`{ a: undefined }`
* `{}`). Normalize BOTH sides through the same round-trip before
* comparing. Returns the input unchanged if it can't be serialized.
*/
export function normalizeForCompare<V>(value: V | undefined): V | undefined {
if (value === undefined) return undefined
@@ -325,20 +281,16 @@ export function normalizeForCompare<V>(value: V | undefined): V | undefined {
}
/**
* Whether the persisted local autosave (`localDraft`, as returned by
* `UserDraft.get`) meaningfully differs from the freshly-built
* Whether the current draft differs meaningfully from a freshly-built
* `currentConfig`. Editor restore guards use this to decide whether to
* overlay the local autosave and toast.
* overlay the draft 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.
* Returns `false` when there is no draft. Normalizes both sides (see
* `normalizeForCompare`) so a draft that round-trips equal to the
* deployed config is correctly treated as "no meaningful draft".
*
* Typed as a guard: a `true` result narrows `localDraft` to non-nullish
* `V`, mirroring the `localCfg && …` narrowing it replaces so call sites
* can pass the draft straight into `loadXConfig(...)` without re-checking.
* `V`.
*/
export function localDraftDiffers<V>(
localDraft: V | undefined | null,
@@ -354,25 +306,17 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Static writes are external mutations. Update live observers and
// force the storage slot to match, even if the live entry still has
// its initial-write skip armed.
// Update the reactive cell — preserves any rev meta the
// editor had seeded earlier. The DB sync rides on the
// reactive effect in `acquireEntry`, which observes this
// write and POSTs it to the syncer.
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
const meta = extractMeta(current)
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
// No live handle: preserve any persisted meta so the staleness
// signal survives a write while the editor is closed.
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
try {
localStorage.setItem(
localStorageKey(ws, itemKind, path),
JSON.stringify(stamp(wrap(value, extractMeta(existing))))
)
} catch (e) {
console.error('UserDraft.save: localStorage write failed', e)
entry.state.val = wrap(value, extractMeta(current))
} else {
// No live handle: push directly to the syncer. The next time
// an editor mounts for this (workspace, kind, path) it will
// re-fetch the draft from the backend.
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value })
}
},
@@ -387,37 +331,24 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Static writes represent explicit external draft mutations. A
// freshly acquired live entry may still have the initial-write skip
// armed, so force the storage slot to match the live value.
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
entry.state.val = wrap(value, meta)
return
}
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
// No live handle and `save_draft` requires a value — skip the
// sync on `undefined` (delete-via-static-write), which the route
// can't represent. Use `discard` for that path.
if (value !== undefined) {
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value })
}
},
/**
* 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.
* Read the current draft value from the in-memory cell. Returns
* `undefined` when no editor has mounted a handle for this
* `(workspace, kind, path)` in this tab UserDraft no longer
* persists anywhere local, so loading is the editor's job (fetch
* via `get_draft=true` and seed via `setDraftAndMeta`).
*/
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,
@@ -426,19 +357,13 @@ export const UserDraft = {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
}
return snapshotDraftValue(unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path))))
if (!entry) return undefined
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
},
/**
* Update the rev metadata for an entry without touching the value, and
* persist immediately. Used by editor routes that don't hold a live
* handle (apps, raw apps) they read the local draft via `UserDraft.get`
* and the handle is created later inside the child editor.
*
* No-op when the entry has no draft to attach meta to.
* Update the rev metadata without touching the value. No-op when no
* live entry exists (there's no off-cell place to record meta now).
*/
saveMeta(
itemKind: UserDraftItemKind,
@@ -449,104 +374,132 @@ export const UserDraft = {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
if (current === undefined) return
entry.state.val = wrap(current.value, meta)
}
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
if (existing === undefined) return
persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta)
if (!entry) return
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
if (current === undefined) return
// Meta-only writes shouldn't fire a sync — the DB doesn't store
// rev meta and an empty POST to save_draft is wasteful.
entry.skipNextSync = true
entry.state.val = wrap(current.value, meta)
},
/**
* Read the rev metadata for the entry. Returns an empty object if there
* is no entry. Useful for staleness checks before reading the draft.
* Read the rev metadata for the entry. Returns an empty object if
* there is no live entry. Useful for staleness checks.
*/
getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
return extractMeta(readPersisted<unknown>(localStorageKey(ws, itemKind, path)))
if (!entry) return {}
return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
},
/**
* 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".
* Whether a draft currently exists for `(workspace, itemKind, path)`
* in the in-memory cache. False when no editor has mounted a handle
* for this entry yet.
*/
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
if (!entry) return false
return entry.state.val !== undefined
},
/**
* Whether a live handle/entry is currently mounted for
* `(workspace, itemKind, path)` in this tab regardless of whether
* it holds a draft value yet. Distinct from `has`, which additionally
* requires a non-`undefined` value. Headless callers (the global AI
* chat) use this to decide whether a write should flow through the
* in-memory cell so a mounted editor reflects it reactively or go
* straight to the DB syncer.
*/
isLive(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
const ws = resolveWorkspace(opts)
return entries.has(mapKey(ws, itemKind, path))
},
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)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Drop the in-memory cell value too so any live observer
// reflects the delete immediately. Arm `skipNextSync` so the
// reactive effect doesn't re-fire (we're already POSTing the
// `null` below).
entry.skipNextSync = true
entry.state.val = undefined
}
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
},
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
UserDraft.discard(itemKind, path, undefined, opts)
},
/**
* Suspend the reactive sync for `(workspace, itemKind, path)`.
* Writes after this call still update the in-memory cell and any
* subscribers but don't POST to the syncer. Use to bracket
* programmatic mutations that happen during editor bootstrap (e.g.
* seeding script content from `initialCode`, low-code app init)
* so they don't appear on the server as the user's "first edit".
*
* Safe to call BEFORE the entry is live the suspension is queued
* and applied when `acquireEntry` runs. Pair every `stopSync` with
* a `restartSync` forgetting to resume silently turns off
* autosave for the rest of the session.
*/
stopSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) entry.syncSuspended = true
else pendingSuspensions.add(mk)
},
/**
* Resume reactive sync for `(workspace, itemKind, path)` after a
* `stopSync`. Subsequent writes that differ from the suspended-time
* state are POSTed normally; writes made during the suspension are
* dropped from the server's view (the local cell still reflects
* them). Also clears any queued (pre-acquire) suspension.
*/
restartSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
pendingSuspensions.delete(mk)
const entry = entries.get(mk)
if (entry) entry.syncSuspended = false
},
/**
* List currently-mounted live entries for `workspace`. Without the
* localStorage layer, "list" is meaningful only for in-tab entries
* for a workspace-wide view across sessions, use the deployed list
* endpoints with `includeDraftOnly` (which flag the caller's drafts).
*/
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
const ws = resolveWorkspace(opts)
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
const out = new Map<string, UserDraftEntry<V>>()
if (typeof localStorage !== 'undefined') {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key)
}
for (const key of keys) {
const parsed = parseLocalStorageKey(key, ws, itemKinds)
if (!parsed) continue
const stored = readPersisted<V>(key)
if (stored === undefined) continue
out.set(mapKey(ws, parsed.itemKind, parsed.path), {
workspace: ws,
itemKind: parsed.itemKind,
path: parsed.path,
value: snapshotDraftValue(unwrap(stored)),
meta: extractMeta(stored),
persisted: true,
live: false
})
}
}
const out: UserDraftEntry<V>[] = []
for (const entry of entries.values()) {
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
const stored = untrack(() => entry.state.val as StoredDraft<V> | undefined)
const mk = mapKey(entry.workspace, entry.itemKind, entry.path)
if (stored === undefined) {
out.delete(mk)
continue
}
const existing = out.get(mk)
out.set(mk, {
if (stored === undefined) continue
out.push({
workspace: entry.workspace,
itemKind: entry.itemKind,
path: entry.path,
value: snapshotDraftValue(unwrap(stored)),
meta: extractMeta(stored),
persisted: existing?.persisted ?? false,
live: true
meta: extractMeta(stored)
})
}
return Array.from(out.values())
return out
},
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
@@ -580,14 +533,16 @@ export const UserDraft = {
/**
* 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`.
* suppresses the reactive sync the explicit `value: null` POST
* below is the canonical delete, the cell update is just a UI
* convenience for the deployed-baseline.
*
* `fallback` is deep-cloned before being installed otherwise a caller
* who passes their own live `$state` baseline (e.g. resource/variable
* editors' `initialStates[ws]`) would end up with `handle.draft` and the
* baseline pointing at the *same* proxy; subsequent edits would mutate
* both sides in lock-step and the dirty check would never fire.
* `fallback` is deep-cloned before being installed otherwise a
* caller who passes their own live `$state` baseline (e.g.
* resource/variable editors' `initialStates[ws]`) would end up with
* `handle.draft` and the baseline pointing at the *same* proxy;
* subsequent edits would mutate both sides in lock-step and the
* dirty check would never fire.
*/
discard<V>(
itemKind: UserDraftItemKind,
@@ -600,49 +555,36 @@ export const UserDraft = {
const entry = entries.get(mk)
const safeFallback = snapshotDraftValue(fallback)
if (entry) {
// Drop any queued debounced write owned by this live entry before
// resetting the in-memory value. Otherwise a timer from the old
// entry can outlive unmount and later delete a freshly written
// draft for the same key.
entry.state.setWithoutPersist(wrap(safeFallback) as StoredDraft<unknown> | undefined)
}
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
} catch (e) {
console.error('UserDraft.discard: localStorage remove failed', e)
entry.skipNextSync = true
entry.state.val = wrap(safeFallback) as StoredDraft<unknown> | undefined
}
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
},
use<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftUseOptions<V>
opts?: UserDraftOptions
): 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.
// `use()` is a single-spec wrapper around `useMany`. We untrack
// the getter so reactive opts (e.g. `$workspaceStore`) are
// captured once at call time — the current 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
}
])
untrack(() => [{ itemKind, path, workspace: opts?.workspace }])
)
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.
// 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>>()
@@ -678,20 +620,20 @@ export const UserDraft = {
}
// Skip no-op mutations (handles are cached by mapKey, so an
// unchanged spec set yields reference-equal arrays). `untrack` so
// this effect doesn't subscribe to its own `handles` write —
// otherwise it self-loops (`effect_update_depth_exceeded`).
// Downstream notification still propagates.
// 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`).
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.
// 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 subsequent changes.
untrack(reconcile)
$effect(reconcile)
onDestroy(() => {
@@ -708,7 +650,7 @@ function acquireEntry(
workspace: string,
itemKind: UserDraftItemKind,
path: string,
defaultValue: unknown
defaultValue?: unknown
): void {
const mk = mapKey(workspace, itemKind, path)
const existing = entries.get(mk)
@@ -716,40 +658,104 @@ function acquireEntry(
existing.count++
return
}
// `useLocalStorageValue`'s internal persist `$effect` would otherwise
// parent to `useMany`'s reconcile effect and be torn down on the next
// reconcile. `$effect.root` gives the entry its own scope, disposed only
// by `releaseEntry`.
const useLocalStorageOptions = {
// First value is the baseline (don't persist it); coalesce edits.
saveInitialValue: false,
debounce: 500,
// Stamp `lastWrittenAt` at persist time so deep mutations also bump
// the GC clock (the setter doesn't re-run for those).
transformBeforePersist: stamp<unknown>
} as const
// Seed the cell with the caller's `defaultValue` (deep-cloned so the
// cell owns its copy and the caller's baseline can't alias it). This is
// how editors report the deployed/draft state until the user edits —
// the sync effect treats this first write as the seed and never POSTs
// it (see `lastSerialized`/`skipNextWrite` below).
const seed =
defaultValue !== undefined
? (wrap(snapshotDraftValue(defaultValue)) as StoredDraft<unknown> | undefined)
: undefined
// `$effect.root` gives the entry its own scope, disposed only by
// `releaseEntry`. Without that, the sync `$effect` would parent to
// `useMany`'s reconcile effect and be torn down on the next
// reconcile.
let stateRef: DraftState<unknown> | undefined
const destroyRoot = $effect.root(() => {
stateRef = useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(workspace, itemKind, path),
wrap(defaultValue),
undefined,
useLocalStorageOptions
)
const cell = $state<{ val: StoredDraft<unknown> | undefined }>({ val: seed })
stateRef = cell
// Mirror every observable change of `cell.val` to the DB
// syncer. Reading `cell.val` alone only subscribes to the proxy
// root, so deep mutations (`handle.draft.content = '...'`)
// would slip past; `readFieldsRecursively` walks the value so
// the effect re-fires on nested writes too.
//
// `lastSerialized` + `skipNextWrite` mirror the dedup pattern
// useLocalStorageValue used to have for `saveInitialValue=false`:
// the effect ignores no-op `val` updates, and treats the FIRST
// observable change after mount as the seed/restore (no sync).
// That matches the editor's UX where landing on `?new_draft`
// or seeding the deployed baseline shouldn't fire a POST until
// the user actually edits something.
//
// `stored === undefined` is the delete signal — the server
// route accepts `value: null` for that. `skipNextSync` lets
// callers that already POSTed (e.g. `discard`, `remove`,
// `saveMeta`) suppress a duplicate fire from their own
// reactive write.
// Start at `undefined` even when the cell was seeded above: that way
// the seed is the FIRST observable change the effect sees and gets
// swallowed by `skipNextWrite`, so seeding the deployed/draft
// baseline never POSTs. The user's first real edit is then the first
// synced write.
let lastSerialized: string | undefined = undefined
let skipNextWrite = true
$effect(() => {
const stored = cell.val
if (stored !== undefined) readFieldsRecursively(stored.value)
const next = stored === undefined ? undefined : JSON.stringify(stored)
if (next === lastSerialized) return
lastSerialized = next
if (skipNextWrite) {
skipNextWrite = false
return
}
const entry = entries.get(mk)
if (entry?.skipNextSync) {
entry.skipNextSync = false
return
}
// `syncSuspended` swallows the POST but still advances
// `lastSerialized` (above) so when sync resumes the next
// real change is detected as a change — only the writes
// made during suspension are dropped from the server's view.
if (entry?.syncSuspended) return
void UserDraftDbSyncer.save({
workspace,
itemKind,
path,
value: stored === undefined ? null : stored.value
})
})
})
if (stateRef) {
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
entries.set(mk, {
count: 1,
workspace,
itemKind,
path,
state: stateRef,
skipNextSync: false,
syncSuspended: pendingSuspensions.delete(mk),
destroyRoot
})
return
}
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
// invoked. Unreachable in production (Svelte runs it synchronously).
const state = useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(workspace, itemKind, path),
wrap(defaultValue),
undefined,
useLocalStorageOptions
)
entries.set(mk, { count: 1, workspace, itemKind, path, state })
// Fallback for the vitest runtime where `$effect.root`'s callback
// isn't invoked. Unreachable in production (Svelte runs it
// synchronously). The fallback cell has no sync effect, so writes
// in tests stay in-memory.
const fallback = $state<{ val: StoredDraft<unknown> | undefined }>({ val: seed })
entries.set(mk, {
count: 1,
workspace,
itemKind,
path,
state: fallback,
skipNextSync: false,
syncSuspended: pendingSuspensions.delete(mk)
})
}
function releaseEntry(mk: string): void {
@@ -767,11 +773,12 @@ function makeHandle<V>(
itemKind: UserDraftItemKind,
path: string
): UserDraftHandle<V> {
// The handle reads `entries.get(mk)` on every access. The entry it points
// at is stable as long as the refcount stays > 0 (which `useMany` keeps
// the case for as long as a spec references it). If the refcount drops to
// 0 and the entry is destroyed, reads return `undefined` rather than
// throwing — the consumer should already have been torn down by that point.
// 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 {
@@ -779,11 +786,11 @@ function makeHandle<V>(
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).
// 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)
@@ -797,65 +804,17 @@ function makeHandle<V>(
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.
setMeta(meta: UserDraftMeta, _opts?: { force?: boolean }): void {
// `force` was useful when there was a localStorage layer to
// write through synchronously. Kept in the signature for
// source compatibility but ignored now.
const state = stateOf()
if (!state) return
const current = untrack(() => state.val as StoredDraft<V> | undefined)
if (current === undefined) return
const entry = entries.get(mk)
if (entry) entry.skipNextSync = true
state.val = wrap(current.value, meta)
if (opts?.force) {
persistDirect(localStorageKey(workspace, itemKind, path), current.value, meta)
}
}
}
}
/**
* Default GC retention window: 30 days. Entries that haven't been touched
* (no setter call, no deep-mutation persist) for this long are swept on
* the next `gcUserDrafts` invocation.
*/
export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
/**
* Sweep stale UserDraft entries from localStorage. Walks every
* `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes
* any entry older than `maxAgeMs`.
*
* Entries written before `lastWrittenAt` was introduced lack the field;
* we backfill them to `now()` on first sighting so they participate in
* the next sweep cycle rather than getting wiped immediately.
*
* Safe to call on every load and on a timer (e.g. every 30 min) live
* entries get their stamp refreshed on every persist, so the sweep only
* touches truly stale records.
*/
export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void {
if (typeof localStorage === 'undefined') return
const now = Date.now()
const cutoff = now - maxAgeMs
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k != null && k.startsWith('userdraft/w/')) keys.push(k)
}
for (const key of keys) {
try {
const raw = localStorage.getItem(key)
if (raw == null) continue
const parsed = JSON.parse(raw)
if (parsed == null || typeof parsed !== 'object') continue
if (typeof parsed.lastWrittenAt !== 'number') {
// Pre-GC-feature entry. Backfill so the next sweep can decide.
parsed.lastWrittenAt = now
localStorage.setItem(key, JSON.stringify(parsed))
continue
}
if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key)
} catch (e) {
console.error('UserDraft GC: failed to inspect', key, e)
}
}
}

Some files were not shown because too many files have changed in this diff Show More