fix: unify draft path on content path, drop redundant draft_path

Editors used to store a renamed draft's target path in a separate
`draft_path` field (flows/apps/raw-apps) while keeping the deployed path in
the content's own `path`. This was redundant — the original/deployed path is
the URL — and it broke the "path already used" guard: editors passed the
renamed `draft_path` as the Path widget's `initialPath`, so reverting a draft
back to the original path falsely tripped "path already used".

Now every editor binds the Path widget to the content's own `path` (like the
script editor already did), and `initialPath` is always the deployed/original
path. A never-deployed draft seeds the baseline from its own friendly path so
the synthetic `draft_{uuid}` storage key isn't flagged as a rename.

The computed `draft_path` field on list/draft API responses is kept (it's how
the home/Review/Drafts lists render the friendly name) but is now sourced
uniformly from `value->>'path'` for all kinds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-19 09:15:34 +02:00
co-authored by Claude Opus 4.8
parent 5508f1da9c
commit 41b62efd6c
32 changed files with 224 additions and 251 deletions
+4 -4
View File
@@ -269,11 +269,11 @@ async fn list_flows(
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 Path widget binds `$pathStore` one-way (`flow.path → $pathStore`),
// so the editor writes a separate `draft_path` field only when the typed
// path differs from the deployed one. `None` = unchanged.
// The Path widget binds the flow's own `path`, so the draft JSON's
// `path` IS the user-typed target. Surface it when it differs from
// the storage path. `None` = unchanged.
let draft_path = v
.get("draft_path")
.get("path")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != row.path.as_str())
.map(|s| s.to_string());
+3 -3
View File
@@ -12212,7 +12212,7 @@
},
"draft_path": {
"type": "string",
"description": "User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)."
"description": "User-typed friendly path from the draft JSON's own `path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)."
},
"draft_only": {
"type": "boolean",
@@ -14994,7 +14994,7 @@
},
"draft_path": {
"type": "string",
"description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's\n`draft_path` field (the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n"
"description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's own `path`\n(the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n"
},
"draft_users": {
"description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n",
@@ -43207,7 +43207,7 @@
},
"draft_path": {
"type": "string",
"description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's `draft_path` field (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n"
"description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's own `path` (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n"
},
"draft_users": {
"description": "Workspace users (including the authed user, and the legacy\nNULL-email row if any) who have a per-user draft at this\npath. Drives the home page's user-avatar circles inside the\nDraft badge. Omitted when no drafts exist.\n",
+5 -5
View File
@@ -12761,8 +12761,8 @@ paths:
draft_path:
type: string
description: >-
User-typed friendly path from the draft JSON's
`draft_path`, when set and different from the storage
User-typed friendly path from the draft JSON's own
`path`, when set and different from the storage
path (e.g. a never-deployed item parked at
`u/{user}/draft_{uuid}`).
draft_only:
@@ -15562,8 +15562,8 @@ paths:
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
yet deployed. Sourced from the draft JSON's own
`path` (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
@@ -16775,7 +16775,7 @@ paths:
User-typed path the editor has staged but not yet
deployed.
Sourced from the draft JSON's `draft_path` field (the
Sourced from the draft JSON's own `path` (the
editor
only writes it when the typed path differs from the
+11 -12
View File
@@ -7934,7 +7934,7 @@ paths:
description: Best-effort, read from the draft JSON's `summary` field when the editor shape carries one.
draft_path:
type: string
description: User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`).
description: User-typed friendly path from the draft JSON's own `path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`).
draft_only:
type: boolean
description: No deployed counterpart exists at this path — the draft is the whole item.
@@ -9702,13 +9702,12 @@ paths:
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.
yet deployed. Sourced from the draft JSON's own
`path` (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.
draft_users:
description: |
Workspace users (including the authed user, and
@@ -27400,10 +27399,10 @@ components:
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
Sourced from the draft JSON's own `path` (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.
draft_users:
description: |
+4 -4
View File
@@ -171,7 +171,7 @@ pub struct ListableApp {
/// over the deployed row). See ListableScript in windmill-types/src/scripts.rs.
#[serde(default, skip_serializing_if = "is_false")]
pub is_draft: bool,
/// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged.
/// User-typed staged path from the draft JSON's own `path`; `None` = unchanged.
/// See ListableScript in windmill-types/src/scripts.rs.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
@@ -505,10 +505,10 @@ async fn list_apps(
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 with no `path`, so the editor
// writes a separate `draft_path` only when it differs from deployed; see flows.rs.
// App/raw-app drafts carry the user-typed path in the value's own
// `path`; surface it when it differs from the storage path. See flows.rs.
let draft_path = v
.get("draft_path")
.get("path")
.and_then(|s| s.as_str())
.filter(|s| !s.is_empty() && *s != row.path.as_str())
.map(|s| s.to_string());
+11 -18
View File
@@ -36,11 +36,11 @@ pub struct DraftListItem {
/// Best-effort, read from the draft JSON's `summary` field when present.
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// User-typed friendly path read from the draft JSON's `draft_path` (set by
/// the editors when it differs from the storage path, e.g. a never-deployed
/// item parked at `u/{user}/draft_{uuid}`). `None` when absent. Lets the
/// review page show the friendly name instead of the storage path, like the
/// home-page list endpoints.
/// User-typed friendly path read from the draft JSON's own `path`, when it
/// differs from the storage path (e.g. a never-deployed item parked at
/// `u/{user}/draft_{uuid}`). `None` when absent. Lets the review page show
/// the friendly name instead of the storage path, like the home-page list
/// endpoints.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
/// No deployed counterpart exists at this path — the draft is the whole
@@ -195,19 +195,12 @@ fn list_drafts_query(all_users: bool) -> String {
d.created_at,
d.value ->> 'summary' AS summary,
{draft_users} AS draft_users,
-- Friendly typed path, by kind (mirrors the home-page list
-- endpoints): scripts bind the Path widget to `script.path`,
-- so it round-trips through the draft JSON's own `path`;
-- flows/apps/raw-apps carry a separate `draft_path`. NULLIF
-- drops it when empty or equal to the storage path.
NULLIF(
NULLIF(
CASE WHEN d.typ::text = 'script'
THEN d.value ->> 'path'
ELSE d.value ->> 'draft_path' END,
''),
d.path
) AS draft_path,
-- Friendly typed path: every editor binds the Path widget to
-- the content's own `path`, so the draft JSON's `path` IS the
-- user-typed target (a never-deployed item is parked at a
-- synthetic draft storage key). NULLIF drops it when empty or
-- equal to the storage path.
NULLIF(NULLIF(d.value ->> 'path', ''), d.path) AS draft_path,
(d.email IS NULL) AS legacy_draft,
(d.email = $2 OR d.email IS NULL) AS mine,
{case} AS draft_only
+1 -1
View File
@@ -97,7 +97,7 @@ pub struct ListableFlow {
/// over the deployed row). See ListableScript in scripts.rs.
#[serde(default)]
pub is_draft: bool,
/// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged.
/// User-typed staged path from the draft JSON's own `path`; `None` = unchanged.
/// See ListableScript in scripts.rs.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
+3 -3
View File
@@ -470,9 +470,9 @@ pub struct ListableScript {
#[serde(skip_serializing_if = "is_false")]
pub is_draft: bool,
/// User-typed staged path, so the home list shows a meaningful name over the
/// autogenerated `u/{user}/draft_{uuid}`. Sourced from the draft JSON: scripts use
/// `value.path` (the Path widget binds `script.path`); flows/apps/raw apps use an
/// explicit `value.draft_path` written only when it differs from deployed. `None` = unchanged.
/// autogenerated `u/{user}/draft_{uuid}`. Every editor binds the Path widget to
/// the content's own `path`, so it's sourced from the draft JSON's `value.path`
/// when it differs from the storage path. `None` = unchanged.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_path: Option<String>,
+11 -15
View File
@@ -938,25 +938,21 @@
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.
// Mirror the user-typed path into the autosaved Flow's own `path`. The Path
// widget binds `$pathStore` (the popover input); the effect above seeds it
// one-way from `flow.path`, and this one writes edits back — so `flow.path`
// IS the typed target (like the script editor's `script.path`). Without it,
// the friendly auto-name on `/flows/add` and any in-place rename never reach
// the autosaved Flow, so the home-list draft row keeps showing the
// autogenerated `u/{user}/draft_{uuid}` slot. The deployed/original path is
// `initialPath` (the URL), not `flow.path`. Deploy clears the whole draft.
$effect(() => {
const typed = $pathStore
const baseline = (flowStore.val as Flow | undefined)?.path ?? ''
const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined
const flow = flowStore.val as Flow | 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
if (typed != undefined && typed !== '' && flow.path !== typed) {
flow.path = typed
}
})
})
@@ -26,9 +26,9 @@
}
// Stable per-user draft storage key. Captured once so editing the flow's path
// (which lives in `draft_path`, not the storage key) can't re-key the autosave
// handle and orphan the draft. Mirrors the full-page editor keying on the URL
// path; falls back through the SDK's path inputs.
// (the typed name lives in the flow value's own `path`, not the storage key)
// can't re-key the autosave handle and orphan the draft. Mirrors the full-page
// editor keying on the URL path; falls back through the SDK's path inputs.
const draftStoragePath = untrack(
() =>
props.initialPath ||
@@ -176,19 +176,23 @@
darkMode
} = getContext<AppViewerContext>('AppViewerContext')
// Mirror the user-typed path into the draft as `draft_path` when it differs
// from the baseline, so the home row shows the friendly name instead of
// `draft_{uuid}`. Drop the field once it matches the baseline again.
// Mirror the user-typed path into the autosaved App's own `path` when it
// differs from the deployed baseline, so the home row shows the friendly name
// instead of the `draft_{uuid}` storage key (and the backend reads it from
// `value->>'path'`). Only-when-different keeps an unedited draft byte-equal to
// the deployed value (which carries no `path`) so it still dedups/discards;
// drop the field once it matches the baseline again. The deployed/original
// path is the URL, not this field.
$effect(() => {
const typed = newEditedPath
const baseline = savedApp?.path ?? ''
const a = $app
const a = $app as (App & { path?: string }) | undefined
if (!a) return
untrack(() => {
if (typed && typed !== baseline) {
a.draft_path = typed
} else if (a.draft_path !== undefined) {
delete a.draft_path
a.path = typed
} else if (a.path !== undefined) {
delete a.path
}
})
})
@@ -197,7 +201,7 @@
// autosave stores the bare App value, which has no summary of its own — it
// lives in the `app` table column, set only on deploy). Without this the
// summary is lost when reopening a draft or deploying it from the Review &
// Deploy page. Parallels `draft_path`.
// Deploy page. Parallels the path mirror above.
$effect(() => {
const s = $summary
const a = $app
@@ -219,7 +219,7 @@
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path={newEditedPath}
initialPath={newPath}
initialPath={newApp ? newPath : (savedApp?.path ?? newPath)}
namePlaceholder="app"
kind="app"
autofocus={false}
+9 -8
View File
@@ -200,18 +200,19 @@ export type App = {
version?: number
/**
* User-typed path persisted on the autosaved App when it differs from
* the deployed/seeded baseline. The home list renders it so a friendly
* name shows up instead of the autogenerated `u/{user}/draft_{uuid}`
* URL slot for renames-in-progress and brand-new drafts. Dropped from
* the JSON once the typed path matches the baseline again, and deploy
* clears the whole draft.
* the deployed/seeded baseline. The home/review/Drafts lists render it (read
* from `value->>'path'`) so a friendly name shows up instead of the
* autogenerated `u/{user}/draft_{uuid}` storage key for renames-in-progress
* and brand-new drafts. Dropped from the JSON once the typed path matches the
* baseline again, and deploy clears the whole draft. The deployed/original
* path is the URL, not this field.
*/
draft_path?: string
path?: string
/**
* App summary persisted on the autosaved App so a draft round-trips it — the
* autosave stores the bare App value, which otherwise drops the summary (it
* normally lives in the `app` table column, set only on deploy). Mirrors
* `draft_path`: draft-only metadata; the deployed summary column is
* normally lives in the `app` table column, set only on deploy). Mirrors the
* `path` field above: draft-only metadata; the deployed summary column is
* authoritative, and the Review & Deploy page reads it from the draft and
* sends it as the summary on deploy.
*/
@@ -103,8 +103,8 @@
* still toggle the mode after mount; this prop only seeds the
* initial state. */
defaultSplitWithPreview?: boolean
/** User-typed path when it differs from `savedApp.path`. The route injects
* it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */
/** User-typed path when it differs from `savedApp.path`. The route persists
* it as the draft's own `path` so the home row shows the friendly name. */
pendingDraftPath?: string | undefined
// Threaded to the AutosaveIndicator's "Reset to deployed" button.
onResetToDeployed?: () => void | Promise<void>
@@ -139,7 +139,7 @@
/** 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
* draft as its own `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
@@ -62,8 +62,9 @@
const kind = DEPLOY_KIND_BY_DRAFT_KIND[baseKind] ?? baseKind
donly[`${kind}/${it.path}`] = it.draft_only
// A never-deployed app/raw_app is parked at a synthetic `…/draft_<uuid>`
// storage path with the user's typed name in `draft_path`; show that
// (matches the home list) while `path` stays the storage key for loading.
// storage path; the backend surfaces the user's typed name as the
// computed `draft_path` field — show that (matches the home list) while
// `path` stays the storage key for loading.
// `summary` comes straight from the draft row, so it shows for every kind
// up front instead of only after the diff value loads.
return {
@@ -79,14 +79,19 @@
newFlow: a draft-only flow has a synthesized `savedFlow` (no_deployed=true)
but no deployed row, so it must deploy via createFlow — treating it as
!newFlow would updateFlow the draft_<uuid> path and 404 "Flow not found".
initialPath: a brand-new flow is stored under a `draft_<uuid>` path with
its intended name in `draft_path`; seed the builder from `draft_path`
(as the full-page editor does) so the Path widget and deploy use the
friendly name rather than creating a flow literally named draft_<uuid>. -->
initialPath: the Path widget's rename baseline. For a deployed flow
it's the URL/storage path (deployed/original); the draft's intended
name rides in the flow value's own `path`, which FlowBuilder seeds into
the Path widget — so the topbar shows the friendly name while
`initialPath` stays the original. A never-deployed flow (parked at a
`draft_<uuid>` key) has no original, so seed the baseline from its own
friendly path instead of flagging the storage key as a rename. -->
<FlowBuilder
flowStore={runtime.flowStore}
flowStateStore={runtime.flowStateStore}
initialPath={(runtime.savedFlow.val as any)?.draft_path ?? path}
initialPath={runtime.savedFlow.val?.no_deployed
? (runtime.flowStore.val?.path ?? path)
: path}
autosaveWorkspace={workspaceId}
autosavePath={path}
newFlow={!runtime.savedFlow.val || runtime.savedFlow.val.no_deployed === true}
@@ -31,16 +31,16 @@
let diffDrawer: DiffDrawer | undefined = $state()
// Path typed in the editor header, surfaced when it differs from the stored
// path. Mirror it into the runtime draft as `draft_path` so the rename
// mutates runtime.rawApp.val → the autosave sig changes → the draft is saved
// (and the home/review/Drafts lists show the friendly name). Mirrors the
// full-page /apps_raw/edit route.
// path. Mirror it into the runtime draft as `typedPath` (persisted as the
// draft's own `path`) so the rename mutates runtime.rawApp.val → the autosave
// sig changes → the draft is saved (and the home/review/Drafts lists show the
// friendly name). Mirrors the full-page /apps_raw/edit route.
let pendingDraftPath = $state<string | undefined>(undefined)
// The header collapses both "not yet bound" and "reverted to baseline" to
// `undefined`. `surfacedDraftPath` tells them apart: the initial undefined
// (before the header binds) must not clobber the `draft_path` seeded by
// (before the header binds) must not clobber the `typedPath` seeded by
// loadRawApp, but a revert/clear after a real typed path must drop the stale
// friendly name — mirroring the script codec's `else delete draft_path`.
// friendly name.
let surfacedDraftPath = false
$effect(() => {
const dp = pendingDraftPath
@@ -49,9 +49,9 @@
if (!val) return
if (dp !== undefined) {
surfacedDraftPath = true
if (val.draft_path !== dp) val.draft_path = dp
} else if (surfacedDraftPath && val.draft_path !== undefined) {
val.draft_path = undefined
if (val.typedPath !== dp) val.typedPath = dp
} else if (surfacedDraftPath && val.typedPath !== undefined) {
val.typedPath = undefined
}
})
})
@@ -104,7 +104,7 @@
bind:data={runtime.rawApp.val.data}
bind:summary={runtime.rawApp.val.summary}
bind:pendingDraftPath
newPath={runtime.rawApp.val.draft_path ?? runtime.rawApp.val.path}
newPath={runtime.rawApp.val.typedPath ?? runtime.rawApp.val.path}
{path}
autosaveWorkspace={workspaceId}
autosavePath={path}
@@ -49,7 +49,7 @@
function buildCodec(): DraftSyncCodec<any> {
if (kind === 'flow') return makeFlowCodec(runtime)
if (kind === 'script') return makeScriptCodec(runtime, () => path)
if (kind === 'script') return makeScriptCodec(runtime)
return makeRawAppCodec(runtime)
}
@@ -60,21 +60,26 @@ describe('appDraftCodec — custom_path round-trip', () => {
})
})
describe('appDraftCodec — draft_path round-trip', () => {
it('serializes draft_path so a path edit changes the draft (and its sig)', () => {
const draft = runtimeRawAppToDraft(runtime({ draft_path: 'u/admin/friendly' }))
expect(draft.draft_path).toBe('u/admin/friendly')
// The autosave keys on JSON.stringify(draft); without draft_path a rename
describe('appDraftCodec — typed path round-trip', () => {
it('serializes the typed path as the draft `path` so an edit changes the draft (and its sig)', () => {
const draft = runtimeRawAppToDraft(runtime({ typedPath: 'u/admin/friendly' }))
expect(draft.path).toBe('u/admin/friendly')
// The autosave keys on JSON.stringify(draft); without the path a rename
// would be invisible and never persist.
expect(JSON.stringify(draft)).toContain('u/admin/friendly')
})
it('omits the draft `path` when there is no rename', () => {
const draft = runtimeRawAppToDraft(runtime({ typedPath: undefined }))
expect(draft.path).toBeUndefined()
})
it('survives a full runtime → draft → runtime round-trip', () => {
const original = runtime({ draft_path: 'u/admin/renamed' })
const original = runtime({ typedPath: 'u/admin/renamed' })
const back = applyDraftToRuntimeRawApp(
runtime({ draft_path: undefined }),
runtime({ typedPath: undefined }),
runtimeRawAppToDraft(original)
)
expect(back.draft_path).toBe('u/admin/renamed')
expect(back.typedPath).toBe('u/admin/renamed')
})
})
@@ -11,16 +11,18 @@ export type RawAppDraft = {
summary: string
policy?: any
custom_path?: string
// User-typed path while the app is parked at a `…/draft_<uuid>` storage path.
// Must round-trip through the draft so the home/review/Drafts lists render the
// friendly name (they read `value->>'draft_path'`) — and so editing the path
// in the editor changes the persisted draft and triggers an autosave.
draft_path?: string
// User-typed path while the app is parked at a `…/draft_<uuid>` storage key.
// Round-trips through the draft as its own `path` so the home/review/Drafts
// lists render the friendly name (they read `value->>'path'`) — and so editing
// the path in the editor changes the persisted draft and triggers an autosave.
// Present only on a rename; the storage key is the URL, not this field.
path?: string
}
// The shape `runtime.rawApp.val` actually holds (see SessionRuntime in
// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and
// makes `policy` required for the editor's live binding.
// sessionRuntime.svelte.ts). `path` is the storage key (a runtime-only field,
// the session editor target); `typedPath` is the user-typed rename, set only
// when it differs from the deployed path and persisted as the draft's own `path`.
export type RuntimeRawApp = {
summary: string
path: string
@@ -29,12 +31,13 @@ export type RuntimeRawApp = {
data: RawAppData
policy: any
custom_path?: string
draft_path?: string
typedPath?: string
}
// Strip runtime-only metadata (just `path`, the storage key) when persisting
// to UserDraft. `custom_path` is a real draft field and must round-trip — else
// session sync erases a draft's custom URL.
// Strip runtime-only metadata (the storage-key `path`) when persisting to
// UserDraft, and surface the user-typed rename under the draft's own `path` so
// the backend lists read it from `value->>'path'`. `custom_path` is a real draft
// field and must round-trip — else session sync erases a draft's custom URL.
export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
return {
summary: raw.summary,
@@ -43,12 +46,13 @@ export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
data: raw.data,
policy: raw.policy,
custom_path: raw.custom_path,
draft_path: raw.draft_path
...(raw.typedPath ? { path: raw.typedPath } : {})
}
}
// Overlay a UserDraft-stored raw-app draft onto an existing runtime raw app,
// preserving the runtime-only `path` field.
// preserving the runtime-only storage-key `path` and mapping the draft's `path`
// (the typed rename) back onto `typedPath`.
export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): RuntimeRawApp {
return {
...raw,
@@ -58,6 +62,6 @@ export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft):
data: dv.data,
policy: dv.policy ?? raw.policy,
custom_path: dv.custom_path ?? raw.custom_path,
draft_path: dv.draft_path ?? raw.draft_path
typedPath: dv.path ?? raw.typedPath
}
}
@@ -20,14 +20,8 @@ describe('flowDraftSig', () => {
)
})
it('changes when only draft_path changes (path rename triggers a save)', () => {
const base = { value: { modules: [] }, summary: 's', draft_path: 'u/admin/draft_abc' }
const renamed = { ...base, draft_path: 'u/admin/friendly' }
expect(flowDraftSig(base)).not.toBe(flowDraftSig(renamed))
})
it('changes when only path changes', () => {
const base = { value: { modules: [] }, summary: 's', path: 'u/admin/a' }
expect(flowDraftSig(base)).not.toBe(flowDraftSig({ ...base, path: 'u/admin/b' }))
it('changes when only path changes (a rename triggers a save)', () => {
const base = { value: { modules: [] }, summary: 's', path: 'u/admin/draft_abc' }
expect(flowDraftSig(base)).not.toBe(flowDraftSig({ ...base, path: 'u/admin/friendly' }))
})
})
@@ -4,24 +4,22 @@
// this signature to skip no-op work. It MUST include every top-level field the
// editor can change on its own — `summary`, `description`, and the path —
// otherwise a change to only that field produces an identical signature and
// never propagates or persists. The Path widget writes a rename into
// `draft_path` (FlowBuilder mirrors the typed path there while the flow is
// parked at its `…/draft_<uuid>` storage `path`); without it here the rename
// moves no signature and the draft is never saved.
// never propagates or persists. FlowBuilder mirrors the Path widget's typed
// path into the flow's own `path` (the flow is parked at its `…/draft_<uuid>`
// storage key); without `path` here a rename moves no signature and the draft
// is never saved.
export function flowDraftSig(x: {
value?: unknown
schema?: unknown
summary?: unknown
description?: unknown
path?: unknown
draft_path?: unknown
}): string {
return JSON.stringify({
value: x.value,
schema: x.schema,
summary: x.summary,
description: x.description,
path: x.path,
draft_path: x.draft_path
path: x.path
})
}
@@ -16,31 +16,18 @@ function runtimeWith(script: Partial<NewScript> & { path: string }): SessionRunt
const STORAGE = 'u/admin/draft_abc'
describe('makeScriptCodec — draft_path (path rename)', () => {
it('writes draft_path when the typed path differs from the storage key', () => {
describe('makeScriptCodec — path rename', () => {
it('carries the typed path on the draft so the lists show the friendly name', () => {
const codec = makeScriptCodec(
runtimeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' }),
() => STORAGE
runtimeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' })
)
const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined
expect(draft?.draft_path).toBe('u/admin/friendly')
})
it('drops draft_path when the typed path equals the storage key', () => {
const codec = makeScriptCodec(
runtimeWith({ path: STORAGE, content: 'c', summary: 's' }),
() => STORAGE
)
const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined
expect(draft?.draft_path).toBeUndefined()
const draft = codec.storeToDraft(undefined)
expect(draft?.path).toBe('u/admin/friendly')
})
it('signature changes on a rename, so the outbound sync persists it', () => {
const before = makeScriptCodec(runtimeWith({ path: STORAGE, content: 'c' }), () => STORAGE)
const after = makeScriptCodec(
runtimeWith({ path: 'u/admin/renamed', content: 'c' }),
() => STORAGE
)
const before = makeScriptCodec(runtimeWith({ path: STORAGE, content: 'c' }))
const after = makeScriptCodec(runtimeWith({ path: 'u/admin/renamed', content: 'c' }))
expect(before.sig(before.storeToDraft(undefined)!)).not.toBe(
after.sig(after.storeToDraft(undefined)!)
)
@@ -35,31 +35,25 @@ export function makeFlowCodec(runtime: SessionRuntime): DraftSyncCodec<Flow> {
}
}
// `NewScript` has no `draft_path` of its own; the session editor parks a rename
// there so the home/Drafts lists (which read `draft_path`) show the typed name.
type ScriptDraft = NewScript & { draft_path?: string }
type ScriptDraft = NewScript
export function makeScriptCodec(
runtime: SessionRuntime,
// The draft's storage key (the URL path). A never-deployed script is parked
// here at `…/draft_<uuid>` while the user's typed name lives in `script.path`.
storagePath: () => string
): DraftSyncCodec<ScriptDraft> {
export function makeScriptCodec(runtime: SessionRuntime): DraftSyncCodec<ScriptDraft> {
return {
itemKind: 'script',
// Must include every field write_script can set — not just content —
// else a summary-only/language-only change yields an identical signature
// and the inbound/outbound sync skips it (the chat's change is then
// invisible in the open editor and clobbered by the next content save).
// `draft_path` carries a rename: ScriptBuilder binds the Path widget
// straight to `script.path` (no separate draft field like flow/raw_app),
// so without it here a rename moves no signature and never autosaves.
// `path` carries a rename: ScriptBuilder binds the Path widget straight to
// `script.path`, which IS the draft's path (the backend lists read it from
// `value->>'path'`), so without it here a rename moves no signature and
// never autosaves.
sig: (d) =>
JSON.stringify({
content: d.content ?? '',
summary: d.summary,
language: d.language,
draft_path: d.draft_path
path: d.path
}),
debounceMs: DEBOUNCE_MS,
applyDraftToStore(incoming) {
@@ -74,17 +68,10 @@ export function makeScriptCodec(
const script = runtime.scriptStore.val
if (!script) return undefined
// Merge over the existing entry so fields the preview doesn't edit
// (set by the chat) survive a content-only save.
const merged: ScriptDraft = { ...(current ?? script), ...script }
// Surface a rename to the home/Drafts lists, which read `draft_path`
// (the typed `script.path` is the draft *value*'s path, not its storage
// key). Mirror flow/raw_app: set it only when the typed path differs
// from the storage key, and drop it once it matches again so a revert
// doesn't leave a stale friendly name behind.
const typed = script.path
if (typed && typed !== storagePath()) merged.draft_path = typed
else delete merged.draft_path
return merged
// (set by the chat) survive a content-only save. `script.path` (the
// user-typed name) rides along, so a rename persists and the home/Drafts
// lists show the friendly name.
return { ...(current ?? script), ...script }
}
}
}
@@ -105,7 +105,7 @@ export interface SessionRuntime {
summary: string
path: string
custom_path?: string
draft_path?: string
typedPath?: string
}
| undefined
}
@@ -451,15 +451,11 @@ function createRuntime(session: Session): SessionRuntime {
)
) as NewScript)
: {
// Seed from the draft's own path (a rename lives in `draft_path`,
// else `path`), not the storage key. Otherwise re-seeding a renamed
// Seed from the draft's own `path` (the Path widget binds it
// directly), not the storage key. Otherwise re-seeding a renamed
// never-deployed draft (e.g. a script→script switch re-runs loadScript
// with the draft still in memory) resets the path to `draft_<uuid>`,
// and the next autosave drops `draft_path` — clobbering the rename.
path:
(aiDraft as NewScript & { draft_path?: string }).draft_path ??
aiDraft.path ??
path,
// with the draft still in memory) resets the path to the storage key.
path: aiDraft.path ?? path,
summary: aiDraft.summary ?? '',
content: '',
description: '',
@@ -610,7 +606,7 @@ function createRuntime(session: Session): SessionRuntime {
summary: draftValue?.summary ?? result.summary ?? '',
path: result.path,
custom_path: draftValue?.custom_path ?? result.custom_path,
draft_path: draftValue?.draft_path
typedPath: draftValue?.path
}
// Seed the per-tab last_sync from the server draft's timestamp so
// later saves attach a matching last_sync and the server can reject
+3 -3
View File
@@ -34,10 +34,10 @@ export async function deployRawAppDraft(
const app = await AppService.getAppByPath({ workspace, path, getDraft: true, rawApp: true })
const draft = (app as any).draft
// Deploy at the draft's intended path. A raw-app draft carries the user-typed
// path in `draft_path` (a never-deployed app is parked at a synthetic
// `u/{user}/draft_{uuid}` storage key); the URL `path` below stays that storage
// path in its own `path` (a never-deployed app is parked at a synthetic
// `u/{user}/draft_{uuid}` storage key); the URL `path` arg stays that storage
// key. Falls back to `path` for an unrenamed draft on a deployed app.
const targetPath = draft?.draft_path ?? draft?.path ?? path
const targetPath = draft?.path ?? path
const value = appSourceToDraftValue(draft ?? app, app)
const policy = (await updateRawAppPolicy(
+15 -21
View File
@@ -305,11 +305,10 @@ export async function deployDraft(
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
const d = r.draft ?? r
const requestBody = {
// Deploy at the draft's intended path: flow/app/raw-app drafts keep the
// user-typed path in `draft_path` (a never-deployed item is parked at a
// synthetic `u/{user}/draft_{uuid}` storage key). The URL `path` stays
// that storage key.
path: d.draft_path ?? d.path ?? path,
// Deploy at the draft's intended path: every draft keeps the user-typed
// path in its own `path` (a never-deployed item is parked at a synthetic
// `u/{user}/draft_{uuid}` storage key, the URL `path` arg).
path: d.path ?? path,
summary: d.summary ?? '',
description: d.description ?? '',
value: d.value,
@@ -330,24 +329,19 @@ export async function deployDraft(
await FlowService.updateFlow({ workspace, path, requestBody })
}
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
await deployDraftTriggers(
d.draft_triggers,
workspace,
d.draft_path ?? d.path ?? path,
draftOnly
)
await deployDraftTriggers(d.draft_triggers, workspace, d.path ?? path, draftOnly)
} else if (kind === 'app') {
// `raw_app` is handled above; only visual apps reach here.
const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any
// A visual-app draft is stored as the *bare* app value (grid/theme/...,
// plus a `draft_path` when the path was renamed) — NOT wrapped in
// { value, summary, policy } like script/flow drafts. So the deploy value
// is the draft object itself; fall back to the deployed value when there's
// no draft. `draft_path` and `summary` are draft-only fields mirrored onto
// the App value (the editor drops them on deploy), so strip them from the
// value and apply them as the deploy path / summary column.
// plus its own `path` when renamed) — NOT wrapped in { value, summary,
// policy } like script/flow drafts. So the deploy value is the draft object
// itself; fall back to the deployed value when there's no draft. `path` and
// `summary` are draft-only fields mirrored onto the App value (the editor
// drops them on deploy), so strip them from the value and apply them as the
// deploy path / summary column.
const draft = r.draft as Record<string, any> | undefined
const { draft_path: draftPath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {}
const { path: draftValuePath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {}
// Policy isn't carried in the app draft, so it comes from the deployed app
// (or a default). custom_path requires admin on update; non-admins send
// undefined so the backend preserves the existing route. The draft has no
@@ -357,9 +351,9 @@ export async function deployDraft(
value: appValue,
summary: draftSummary ?? r.summary ?? '',
policy: r.policy ?? { execution_mode: 'publisher' },
// Honor the draft's intended path; `draft_path` holds the user-typed path
// for a never-deployed app parked at a `u/{user}/draft_{uuid}` storage key.
path: draftPath ?? r.path ?? path,
// Honor the draft's intended path; the draft's own `path` holds the
// user-typed path for a never-deployed app parked at a synthetic storage key.
path: draftValuePath ?? r.path ?? path,
custom_path: isAdmin ? (r.custom_path ?? '') : undefined
}
// Same as flows: draft-only apps have no app row → create;
+3 -3
View File
@@ -23,9 +23,9 @@ export interface DraftItem {
kind: DraftKind
path: string
summary?: string
/** User-typed friendly path (from the draft JSON's `draft_path`) when it
* differs from the storage `path` e.g. a never-deployed item parked at
* `u/{user}/draft_{uuid}`. Display this instead of `path` when present. */
/** User-typed friendly path (computed by the backend from the draft JSON's
* own `path`) when it differs from the storage `path` e.g. a never-deployed
* item parked at `u/{user}/draft_{uuid}`. Display this instead of `path`. */
draft_path?: string
/** Never deployed — exists only as a draft. */
draft_only: boolean
@@ -429,7 +429,7 @@
summary={app.summary}
app={app.value}
{deployedBaseline}
newPath={app.value?.draft_path ?? app.path}
newPath={app.value?.path ?? app.path}
path={page.params.path ?? ''}
policy={app.policy}
bind:savedApp
@@ -42,9 +42,9 @@
summary: string
policy?: any
custom_path?: string
/** User-typed path the home list renders, set only when it differs
* from the deployed/seeded `savedApp.path`. */
draft_path?: string
/** User-typed path the home list renders (read from `value->>'path'`), set
* only when it differs from the deployed/seeded `savedApp.path`. */
path?: string
}
let files: Record<string, string> | undefined = $state(undefined)
@@ -56,7 +56,7 @@
let policy: any = $state({})
let summary = $state('')
/** User-typed path from `RawAppEditorHeader` when it differs from
* `savedApp.path`; mirrored into the draft below as `draft_path` for the
* `savedApp.path`; mirrored into the draft below as its own `path` for the
* home list's friendly name. */
let pendingDraftPath = $state<string | undefined>(undefined)
@@ -113,12 +113,12 @@
summary,
policy,
custom_path: savedApp?.custom_path,
// Persist the typed path as `draft_path` only when it actually differs
// from the current path — a `draft_path` equal to the baseline is a
// Persist the typed path as the draft's own `path` only when it actually
// differs from the current path — a `path` equal to the baseline is a
// no-op that would block the draft from deduping against the deployed
// app (which carries none). Drops back out on a revert or deploy.
...(pendingDraftPath && pendingDraftPath !== (savedApp?.path ?? '')
? { draft_path: pendingDraftPath }
? { path: pendingDraftPath }
: {})
} as RawAppDraft
})
@@ -135,9 +135,9 @@
summary = app.summary
// lastVersion = app.version
policy = app.policy
// Prefer the saved `draft_path` so the topbar shows the pending name, not
// the `draft_{uuid}` URL. See /flows/edit's loader.
newPath = (app as any).draft_path ?? app.path
// Deployed/URL path; the loader overrides this with the draft's own typed
// `path` afterwards so the topbar shows the pending name when renamed.
newPath = app.path
}
/** Increments per `loadApp` call. Stale loads (e.g. when picker
@@ -273,7 +273,7 @@
deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined)
// Deployed baseline for the autosave `discardIf`, captured BEFORE the swap
// below mutates `backendApp`. Mirrors the bundle `$effect`'s shape (minus
// the edit-only `draft_path`) so an unedited draft compares equal.
// the edit-only `path`) so an unedited draft compares equal.
// `undefined` when there's no deployed row.
deployedBaseline = backendApp.no_deployed
? undefined
@@ -300,14 +300,9 @@
summary?: string
policy?: any
custom_path?: string
draft_path?: string
path?: string
}
| undefined
// Surface the saved `draft_path` on `backendApp` so `extractRawApp` seeds
// `newPath` with the friendly name, not the `draft_{uuid}` URL.
if (savedRawAppDraft?.draft_path) {
;(backendApp as any).draft_path = savedRawAppDraft.draft_path
}
if (backendApp.no_deployed) {
backendApp.value = {
files: savedRawAppDraft?.files ?? {},
@@ -344,6 +339,10 @@
// $effect re-mirrors them into `draftSync.draft`; the first write is
// swallowed by `acquireEntry`'s seed guard, so no POST.
extractRawApp(backendApp)
// The draft's own typed `path` (set only on a rename) is the friendly name
// the topbar shows; `extractRawApp` seeded `newPath` from the deployed/URL
// path, so override it here. The deployed/original path stays the URL.
if (savedRawAppDraft?.path) newPath = savedRawAppDraft.path
// "Load another user's draft" handoff: their value is a flat RawAppDraft
// bundle. Override the local pieces with it; overlay mode (we have our own
// draft) hard-locks saves until the user confirms overwriting. The bundle
@@ -363,7 +362,7 @@
data = v.data ?? { ...DEFAULT_DATA }
summary = v.summary ?? ''
policy = v.policy ?? {}
newPath = v.draft_path ?? savedApp?.path ?? path
newPath = v.path ?? savedApp?.path ?? path
if (hasOwnDraft) {
OtherUserDraftLoad.beginOverlay({
workspace: $workspaceStore!,
@@ -379,7 +378,7 @@
summary,
policy,
custom_path: savedApp?.custom_path,
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
...(pendingDraftPath ? { path: pendingDraftPath } : {})
} as RawAppDraft,
onResetToOwnDraft: () => loadApp({ getDraft: true })
})
@@ -150,7 +150,7 @@
deployedBaseline = undefined
// Suspend autosave around the bootstrap cascade: the Path widget's
// `initPath → reset → bind:path` chain seeds a friendly auto-name that
// FlowBuilder mirrors into `flow.draft_path` — a programmatic write that
// FlowBuilder mirrors into `flow.path` — a programmatic write that
// must not post as the user's first save. Resume on first interaction
// (with a 5s fallback) rather than guessing the cascade's length.
if ($workspaceStore) {
@@ -349,16 +349,26 @@
? ({ ...deployedFlow, ...draftFromBackend } as Flow)
: (deployedFlow as Flow)
savedFlow = structuredClone($state.snapshot(effectiveFlow)) as Flow
// `savedFlow` is the deployed baseline (diff "deployed" side + restore
// target), so its path must stay the deployed path even when the draft
// overlay renamed `effectiveFlow.path`.
if (!backendFlow.no_deployed) savedFlow.path = (deployedFlow as Flow).path
// Baseline for the autosave `discardIf`: the deployed flow WITHOUT the
// draft overlay (matches the unedited seed when no draft exists).
deployedBaseline = backendFlow.no_deployed
? undefined
: (structuredClone($state.snapshot(deployedFlow)) as Flow)
// Surface the saved `draft_path` to the Path widget so the topbar shows the
// pending name, not the `draft_{uuid}` URL. Else the widget seeds from the
// URL, the first edit clobbers `draft_path`, and the friendly name is lost.
const renderedDraftPath = (effectiveFlow as any).draft_path as string | undefined
if (renderedDraftPath) flowInitialPath = renderedDraftPath
// `flowInitialPath` is the Path widget's rename baseline and the
// `updateFlow` target on deploy. For a deployed flow it stays the URL
// (deployed/original) path, while the draft's renamed path rides in
// `effectiveFlow.path` (seeded into the Path widget via FlowBuilder's
// `$pathStore`) — so the topbar shows the pending name without conflating
// it with the original. A never-deployed draft (parked at a `draft_{uuid}`
// storage key) has no original, so seed the baseline from its own friendly
// path: else the storage key would be flagged as a rename target.
if (backendFlow.no_deployed) {
flowInitialPath = (effectiveFlow.path as string) || flowInitialPath
}
// "Load another user's draft" handoff: render their value over the deployed
// metadata. Overlay mode (we have our own draft) never saves until the user