feat(frontend): dedup user drafts against the deployed baseline (#9618)

This commit is contained in:
Diego Imbert
2026-06-16 17:49:45 +02:00
committed by GitHub
parent 651fa13ee8
commit a2ce44645f
12 changed files with 463 additions and 57 deletions
@@ -34,7 +34,7 @@
sendUserToast,
urlParamsToObject
} from '$lib/utils'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
import AppPreview from './AppPreview.svelte'
import ComponentList from './componentsPanel/ComponentList.svelte'
import ContextPanel from './contextPanel/ContextPanel.svelte'
@@ -69,6 +69,7 @@
path,
policy,
summary,
deployedBaseline = undefined,
fromHub = false,
diffDrawer = undefined,
savedApp = $bindable(undefined),
@@ -87,6 +88,17 @@
migrateApp(untrack(() => app))
// Migrated clone of the deployed baseline for the autosave `discardIf`. The
// live `stateApp` is `migrateApp`'d on mount, so the baseline must be too or
// an unedited draft would never compare equal. Captured once per mount (the
// route remounts AppEditor on path change), `undefined` for draft-only paths.
const migratedDeployedBaseline = untrack(() => {
if (!deployedBaseline) return undefined
const clone = structuredClone($state.snapshot(deployedBaseline)) as App
migrateApp(clone)
return clone
})
// Inside a session pane the AIChatManager is injected via context. Sessions
// have their own state machinery (sessionRuntime + per-fork backend), and
// the user-facing $workspaceStore stays on the main workspace even when
@@ -101,7 +113,13 @@
const appDraftHandle = inSessionPane
? undefined
: // `canBeDisabled`: page editor's AutosaveIndicator carries the toggle.
UserDraft.use<App>('app', appDraftPath, { canBeDisabled: true })
// `discardIf`: an autosave reverting to the deployed app deletes the
// draft instead of persisting a no-op copy.
UserDraft.use<App>('app', appDraftPath, {
canBeDisabled: true,
discardIf: (val) =>
migratedDeployedBaseline !== undefined && draftValuesEqual(val, migratedDeployedBaseline)
})
// Suspend autosave around mount so the `firstMirror` seed write isn't POSTed
// as the user's first edit; `onMount`-then-`tick` resumes once effects settle.
if (appDraftHandle) UserDraft.stopSync('app', appDraftPath)
@@ -0,0 +1,32 @@
import type { App } from './types'
import { gridColumns } from './gridUtils'
import { allItems } from './editor/appUtilsCore'
/**
* Normalize an `App` in place to the current schema: default `hiddenInlineScripts`
* type, migrate the legacy `doNotRecomputeOnInputChanged` flag, and default
* `fullHeight` on every grid item. Lives in its own light module (no app-editor
* component imports) so non-editor callers — e.g. the localStorage→DB draft
* migration — can reuse it without pulling the whole `apps/utils` graph.
*/
export function migrateApp(app: App) {
;(app?.hiddenInlineScripts ?? []).forEach((x) => {
if (x.type == undefined) {
//@ts-ignore
x.type = 'inline'
}
//TODO: remove after migration is done
if (x.doNotRecomputeOnInputChanged != undefined) {
x.recomputeOnInputChanged = !x.doNotRecomputeOnInputChanged
x.doNotRecomputeOnInputChanged = undefined
}
})
allItems(app.grid, app.subgrids).forEach((x) => {
gridColumns.forEach((column: number) => {
if (x?.[column]?.fullHeight === undefined) {
x[column].fullHeight = false
}
})
})
}
@@ -144,6 +144,10 @@ export interface AppEditorProps {
path: string
policy: Policy
summary: string
/** Deployed app value the autosave `discardIf` compares against, so an
* edit reverting to deployed clears the draft instead of leaving a no-op.
* `undefined` for draft-only paths (no deployed baseline). */
deployedBaseline?: App | undefined
fromHub?: boolean
diffDrawer?: DiffDrawerI | undefined
savedApp?:
+14 -28
View File
@@ -2,7 +2,14 @@ import type { Schema } from '$lib/common'
import { twMerge } from 'tailwind-merge'
import { type AppComponent } from './editor/component'
import { isRunnableByName, isRunnableByPath, type AppInput, type InputType, type ResultAppInput, type StaticAppInput } from './inputType'
import {
isRunnableByName,
isRunnableByPath,
type AppInput,
type InputType,
type ResultAppInput,
type StaticAppInput
} from './inputType'
import type { Output } from './rx'
import type {
App,
@@ -11,30 +18,12 @@ import type {
HorizontalAlignment,
VerticalAlignment
} from './types'
import { gridColumns } from './gridUtils'
import { allItems, BG_PREFIX } from './editor/appUtilsCore'
export function migrateApp(app: App) {
;(app?.hiddenInlineScripts ?? []).forEach((x) => {
if (x.type == undefined) {
//@ts-ignore
x.type = 'inline'
}
//TODO: remove after migration is done
if (x.doNotRecomputeOnInputChanged != undefined) {
x.recomputeOnInputChanged = !x.doNotRecomputeOnInputChanged
x.doNotRecomputeOnInputChanged = undefined
}
})
allItems(app.grid, app.subgrids).forEach((x) => {
gridColumns.forEach((column: number) => {
if (x?.[column]?.fullHeight === undefined) {
x[column].fullHeight = false
}
})
})
}
// `migrateApp` moved to its own light module so non-editor callers can reuse it
// without pulling the whole `apps/utils` graph; re-exported here for existing
// `from '../utils'` importers.
export { migrateApp } from './migrateApp'
export function processSubcomponents(data: AppComponent, fn: (data: AppComponent) => void) {
if (data.type == 'tablecomponent' && Array.isArray(data.actionButtons)) {
@@ -133,7 +122,7 @@ export function isScriptByNameDefined(appInput: AppInput | undefined): boolean {
return false
}
if (appInput.type === 'runnable' && isRunnableByName(appInput.runnable)) {
if (appInput.type === 'runnable' && isRunnableByName(appInput.runnable)) {
return appInput.runnable?.name != undefined
}
@@ -402,10 +391,7 @@ export function getAllScriptNames(app: App): string[] {
const names = (allItems(app.grid, app?.subgrids) ?? []).reduce((acc, gridItem: GridItem) => {
const { componentInput } = gridItem.data
if (
componentInput?.type === 'runnable' &&
isRunnableByName(componentInput.runnable)
) {
if (componentInput?.type === 'runnable' && isRunnableByName(componentInput.runnable)) {
acc.push(componentInput.runnable.name)
}
@@ -11,7 +11,7 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
* overwrite-as-fresh), the deployed-baseline `seed`, and the draft `remove`.
* The entity-specific backend load and new-draft template stay in the page.
*/
export interface PageDraftSyncOptions {
export interface PageDraftSyncOptions<V = unknown> {
itemKind: UserDraftItemKind
/** Reactive draft storage path. `''` (e.g. viewing a historical hash)
* releases the handle and skips registry/sync work. */
@@ -22,6 +22,14 @@ export interface PageDraftSyncOptions {
* draft's own `path`, used by home-page deep links). Omit to skip
* registry registration entirely (e.g. read-only hash views). */
effectivePath?: () => string | undefined
/** Predicate: is the value about to autosave back at the deployed
* baseline? When true the syncer POSTs a delete instead of a
* baseline-equal draft, so editing back to deployed clears the draft
* instead of leaving a no-op behind. MUST read the deployed baseline
* reactively (it's captured once per re-keyed acquire) and use
* `draftValuesEqual` so it can't disagree with the "unsaved changes"
* banner. Return false for draft-only items (no deployed baseline). */
discardIf?: (val: V) => boolean
}
export interface PageDraftSync<V> {
@@ -41,14 +49,15 @@ export interface PageDraftSync<V> {
remove(): void
}
export function usePageDraftSync<V = unknown>(opts: PageDraftSyncOptions): PageDraftSync<V> {
export function usePageDraftSync<V = unknown>(opts: PageDraftSyncOptions<V>): PageDraftSync<V> {
// One handle, re-keyed on (workspace, path); `''` path releases it.
// `canBeDisabled` because these editors carry the "Enable auto-save" toggle.
const handle = UserDraft.useReactive<V>(() => ({
itemKind: opts.itemKind,
path: opts.path(),
workspace: opts.workspace(),
canBeDisabled: true
canBeDisabled: true,
discardIf: opts.discardIf
}))
// Live-editor-draft registry: lets the home-page "edit draft" link resolve
+25 -2
View File
@@ -186,11 +186,24 @@ function snapshotDraftValue<V>(value: V | undefined): V | undefined {
* run-as directives, not draft content, and the editor round-trips them
* asymmetrically (`preserve_…` rebuilt as `!!cfg.permissioned_as` on load
* but `|| undefined` on build) — keeping them produces a phantom banner.
*
* The rest are server-managed read-time metadata that ride along on the
* loaded deployed payload but never appear in the editor's draft content, so
* comparing them would mask a true baseline match:
* `draft_saved_at` (the draft's own save time), `edited_at` (deploy time),
* `edited_by` (deploy author), `workspace_id`, `version_id` (deployed version),
* and `is_draft` (backend presence flag).
*/
const DRAFT_COMPARE_IGNORED_FIELDS = [
'permissioned_as',
'preserve_permissioned_as',
'extra_perms'
'extra_perms',
'draft_saved_at',
'edited_at',
'edited_by',
'workspace_id',
'version_id',
'is_draft'
] as const
/**
@@ -453,6 +466,8 @@ export const UserDraft = {
opts?: UserDraftOptions & {
/** See the `useMany` spec field. Default `false`. */
canBeDisabled?: boolean
/** See the `useMany` spec field. Captured once on first acquire. */
discardIf?: (val: V) => boolean
}
): UserDraftHandle<V> {
// Single-spec wrapper around `useMany`. `untrack` captures reactive
@@ -460,7 +475,13 @@ export const UserDraft = {
// workspace until unmount. For reactive `(kind, path)` use `useReactive`.
const handles = UserDraft.useMany<V>(() =>
untrack(() => [
{ itemKind, path, workspace: opts?.workspace, canBeDisabled: opts?.canBeDisabled }
{
itemKind,
path,
workspace: opts?.workspace,
canBeDisabled: opts?.canBeDisabled,
discardIf: opts?.discardIf
}
])
)
return handles[0]
@@ -479,6 +500,8 @@ export const UserDraft = {
path: string
workspace?: string
canBeDisabled?: boolean
/** See the `useMany` spec field. Captured per re-keyed acquire. */
discardIf?: (val: V) => boolean
}
): UserDraftHandle<V> {
const handles = UserDraft.useMany<V>(() => [getSpec()])
@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Service layer is mocked: the migration must dedup against the deployed value
// without making real network calls.
const updateDraft = vi.fn(async (..._args: any[]) => ({ status: 'created' as const }))
const getScriptByPath = vi.fn()
const getFlowByPath = vi.fn()
const getAppByPath = vi.fn()
vi.mock('./gen', () => ({
DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) },
ScriptService: { getScriptByPath: (...a: unknown[]) => getScriptByPath(...(a as [])) },
FlowService: { getFlowByPath: (...a: unknown[]) => getFlowByPath(...(a as [])) },
AppService: { getAppByPath: (...a: unknown[]) => getAppByPath(...(a as [])) }
}))
// `migrateApp` mutates an App in place; the deployed fixtures below are already
// in migrated shape, so a no-op keeps the dedup comparison exact.
vi.mock('./components/apps/migrateApp', () => ({ migrateApp: vi.fn() }))
vi.mock('./toast', () => ({ sendUserToast: vi.fn() }))
vi.mock('./userNamespace', () => ({ getUsernameForNamespace: () => 'me' }))
vi.mock('./utils/uuid', () => ({ randomUUID: () => 'fixed-uuid' }))
import { migrateUserDraftsToDb } from './userDraftDbMigration'
function lsKey(kind: string, path: string): string {
return `userdraft/w/main/${kind}/${path}`
}
function setDraft(kind: string, path: string, value: unknown): string {
const key = lsKey(kind, path)
localStorage.setItem(key, JSON.stringify({ value, lastWrittenAt: 123 }))
return key
}
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
updateDraft.mockResolvedValue({ status: 'created' })
})
describe('migrateUserDraftsToDb dedup', () => {
it('drops a draft deep-equal to the deployed script without uploading it', async () => {
const deployed = { path: 'u/me/s', summary: 'hi', content: 'x', language: 'bun' }
getScriptByPath.mockResolvedValue(deployed)
const key = setDraft('script', 'u/me/s', { ...deployed })
await migrateUserDraftsToDb()
expect(getScriptByPath).toHaveBeenCalledWith({
workspace: 'main',
path: 'u/me/s',
getDraft: false
})
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('uploads a draft that differs from the deployed script', async () => {
getScriptByPath.mockResolvedValue({
path: 'u/me/s',
summary: 'hi',
content: 'x',
language: 'bun'
})
const key = setDraft('script', 'u/me/s', {
path: 'u/me/s',
summary: 'hi',
content: 'EDITED',
language: 'bun'
})
await migrateUserDraftsToDb()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
it('treats `{ field: undefined }` and an absent field as equal (json normalization)', async () => {
// The draft table stores JSON, which strips `undefined` keys — the
// comparison must too, or a draft that only differs by an undefined key
// would never dedup.
getFlowByPath.mockResolvedValue({ summary: 'f', value: { modules: [] } })
const key = setDraft('flow', 'u/me/f', {
summary: 'f',
value: { modules: [] },
labels: undefined
})
await migrateUserDraftsToDb()
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('ignores server-managed metadata fields on the deployed flow payload', async () => {
// The deployed flow carries read-time metadata (workspace_id, edited_by,
// version_id, is_draft, timestamps) that the editor's draft content never
// holds — they must not block the dedup.
getFlowByPath.mockResolvedValue({
workspace_id: 'admins',
path: 'u/me/f',
summary: 'f',
value: { modules: [] },
edited_by: 'admin@windmill.dev',
edited_at: '2026-01-01T00:00:00Z',
archived: false,
schema: {},
extra_perms: {},
version_id: 2,
is_draft: false,
draft_saved_at: '2026-01-01T00:00:01Z'
})
const key = setDraft('flow', 'u/me/f', {
path: 'u/me/f',
summary: 'f',
value: { modules: [] },
archived: false,
schema: {}
})
await migrateUserDraftsToDb()
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('compares app drafts against the deployed `.value`', async () => {
const appValue = {
grid: [],
fullscreen: false,
unusedInlineScripts: [],
hiddenInlineScripts: []
}
getAppByPath.mockResolvedValue({ value: { ...appValue } })
const key = setDraft('app', 'u/me/a', { ...appValue })
await migrateUserDraftsToDb()
expect(getAppByPath).toHaveBeenCalledWith({
workspace: 'main',
path: 'u/me/a',
getDraft: false
})
expect(updateDraft).not.toHaveBeenCalled()
expect(localStorage.getItem(key)).toBeNull()
})
it('uploads when there is no deployed item (fetch rejects)', async () => {
getScriptByPath.mockRejectedValue(new Error('404'))
const key = setDraft('script', 'u/me/new', { path: 'u/me/new', content: 'x' })
await migrateUserDraftsToDb()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
it('skips the deployed fetch for a pathless /add draft and uploads at a minted path', async () => {
// A legacy `/add` autosave has an empty path; there is no deployed item to
// dedup against, so it uploads to a freshly minted `u/{user}/draft_{uuid}`.
setDraft('script', '', { path: '', content: 'x' })
await migrateUserDraftsToDb()
expect(getScriptByPath).not.toHaveBeenCalled()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(updateDraft.mock.calls[0][0]).toMatchObject({
kind: 'script',
// `mintDraftAddPath` dashes→underscores (path segments are word chars).
path: 'u/me/draft_fixed_uuid'
})
})
it('does not dedup unsupported kinds (e.g. variable) — uploads as before', async () => {
const key = setDraft('variable', 'u/me/v', { value: 'secret' })
await migrateUserDraftsToDb()
expect(getScriptByPath).not.toHaveBeenCalled()
expect(getAppByPath).not.toHaveBeenCalled()
expect(updateDraft).toHaveBeenCalledTimes(1)
expect(localStorage.getItem(key)).toBeNull()
})
})
+63 -3
View File
@@ -6,13 +6,18 @@
* success — so it's idempotent without a sentinel; failed entries retry next
* mount. Not workspace-gated: keys embed their own workspace and the token
* covers all of them, so gating would orphan other-workspace entries.
* Deliberately self-contained (no `userDraft.svelte.ts` import) so the
* runtime module stays free of legacy decoders.
*
* Before uploading, each draft is compared against its deployed version
* (script / flow / app); a draft that's deep-equal to what's deployed carries
* no changes, so it's dropped instead of migrated (no error).
*/
import { DraftService } from './gen'
import { AppService, DraftService, FlowService, ScriptService } from './gen'
import type { UserDraftItemKind } from './gen'
import type { App } from './components/apps/types'
import { migrateApp } from './components/apps/migrateApp'
import { sendUserToast } from './toast'
import { draftValuesEqual } from './userDraft.svelte'
import {
openDraftMigrationErrorModal,
reportDraftMigrationError
@@ -121,6 +126,48 @@ function readPayload(key: string): { value: unknown; lastWrittenAt?: number } |
}
}
/**
* Fetch the deployed value for a draft so the migration can drop a draft that
* carries no changes (deep-equal to what's already deployed) instead of
* uploading a no-op that would light up the "unsaved" badge. Returns the
* comparable deployed payload, or `undefined` when there's nothing to compare
* against: an unsupported kind, a pathless (minted `/add`) draft, or a fetch
* miss (404 — the path is draft-only, so the draft is genuinely new). `getDraft`
* is forced off so we compare against the deployed baseline, not our own draft.
*/
async function fetchDeployedValue(
workspace: string,
kind: UserDraftItemKind,
path: string
): Promise<unknown | undefined> {
if (!path) return undefined
try {
switch (kind) {
case 'script':
return await ScriptService.getScriptByPath({ workspace, path, getDraft: false })
case 'flow':
return await FlowService.getFlowByPath({ workspace, path, getDraft: false })
case 'app': {
// The app autosave stores the inner `App`, not the `AppWithLastVersion`
// wrapper getAppByPath returns — compare against `.value`. Run
// `migrateApp` so the deployed value matches the editor-migrated draft
// (AppEditor `migrateApp`s `stateApp` on mount); without this an app
// whose deployed row predates those field migrations never dedups.
const app = await AppService.getAppByPath({ workspace, path, getDraft: false })
const value = (app as { value?: App }).value
if (value) migrateApp(value)
return value
}
default:
return undefined
}
} catch {
// No deployed item at this path (or the fetch failed) — nothing to dedup
// against, so the caller proceeds to upload the draft.
return undefined
}
}
function collectKeys(): string[] {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
@@ -188,6 +235,19 @@ export async function migrateUserDraftsToDb(): Promise<void> {
for (const { key, parsed, path, value, lastWrittenAt } of toMigrate) {
try {
// Dedup: if the draft is deep-equal to the deployed version it carries
// no changes — drop it (no error) instead of uploading a no-op draft.
// Fetches against `parsed.path` (the real item path); minted `/add`
// drafts have `parsed.path === ''` and so are never deduped.
const deployed = await fetchDeployedValue(parsed.workspace, parsed.itemKind, parsed.path)
if (deployed !== undefined && draftValuesEqual(value, deployed)) {
try {
localStorage.removeItem(key)
} catch {
// Best-effort; a stale LS entry is harmless — it re-dedups next mount.
}
continue
}
const res = await DraftService.updateDraft({
workspace: parsed.workspace,
kind: parsed.itemKind,
@@ -36,6 +36,10 @@
/** No deployed app at the URL path. Drives the editor's deploy:
* `createApp` vs `updateApp`. Flips false once a deploy lands here. */
let isNewApp = $state(false)
/** Deployed app value this load, the baseline AppEditor's autosave
* `discardIf` compares against. `undefined` for draft-only paths so they
* never self-destruct by matching a non-existent baseline. */
let deployedBaseline = $state<App | undefined>(undefined)
let otherDraftsUsers = $state<OtherDraftUser[]>([])
let loadedFromDraft = $state(false)
let othersModalOpen = $state(false)
@@ -65,6 +69,8 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// Brand-new app: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
// Capture every seeding param BEFORE stripping the URL flag.
const templatePath = page.url.searchParams.get('template')
const templateId = page.url.searchParams.get('template_id')
@@ -185,6 +191,12 @@
getDraft
})
if (tok !== loadAppToken) return
// Deployed App value for AppEditor's autosave `discardIf`, captured BEFORE
// the draft swap below replaces `backendApp.value`. `undefined` when
// there's no deployed row (draft-only path).
deployedBaseline = backendApp.no_deployed
? undefined
: (structuredClone(stateSnapshot(backendApp.value)) as App)
// `other_drafts_users` only computed when `getDraft`; don't clobber the
// known list on a `getDraft:false` reload. See /scripts/edit's loader.
if (getDraft) {
@@ -343,6 +355,7 @@
on:restore={onRestore}
summary={app.summary}
app={app.value}
{deployedBaseline}
newPath={app.value?.draft_path ?? app.path}
path={page.params.path ?? ''}
policy={app.policy}
@@ -13,7 +13,7 @@
import { page } from '$app/state'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { importStore } from '$lib/components/apps/store'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte'
import { armRestartOnFirstInteraction, runResetToDeployed } from '$lib/userDraftToast'
import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte'
@@ -72,13 +72,21 @@
* React/Svelte + data config + optional AI prompt before the editor goes live. */
let templatePicker = $state(false)
/** Deployed raw-app bundle this load, the baseline the autosave `discardIf`
* compares against. `undefined` for draft-only paths so they never
* self-destruct by matching a non-existent baseline. */
let deployedBaseline = $state<RawAppDraft | undefined>(undefined)
// Page-level draft orchestration. `path` is a mount-scoped plain `let` (the
// editor remounts per path), so this re-keys only on workspace change.
// effectivePath omitted: the live-editor-draft entry is owned by RawAppEditor.
const draftSync = usePageDraftSync<RawAppDraft>({
itemKind: 'raw_app',
path: () => path,
workspace: () => $workspaceStore
workspace: () => $workspaceStore,
// Autosaves landing back on the deployed raw app become deletes, so
// reverting edits clears the draft instead of leaving a no-op behind.
discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline)
})
// Persist the bundle whenever any of the four pieces of state changes.
@@ -98,30 +106,42 @@
summary,
policy,
custom_path: savedApp?.custom_path,
// Only persist when set, so the field disappears from the saved JSON
// once the typed path matches the baseline again (or on deploy).
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
// 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
// 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 }
: {})
} as RawAppDraft
})
function extractRawApp(app: any) {
runnables = app.value.runnables
// Support old formats and new format
if (app.value.data) {
const d = app.value.data
/** Normalize a raw-app `value` into the editor's `data` config, supporting
* the old nested `creation` / `datatables` shapes. `undefined` when the
* value carries no data config (caller keeps the current/default `data`). */
function extractDataConfig(value: any): RawAppData | undefined {
if (value?.data) {
const d = value.data
// Handle old nested creation format
if (d.creation) {
data = {
return {
tables: d.tables ?? [],
datatable: d.creation.datatable,
schema: d.creation.schema
}
} else {
data = d
}
} else if (app.value.datatables) {
data = { ...DEFAULT_DATA, tables: app.value.datatables }
return d
} else if (value?.datatables) {
return { ...DEFAULT_DATA, tables: value.datatables }
}
return undefined
}
function extractRawApp(app: any) {
runnables = app.value.runnables
// Support old formats and new format
const extractedData = extractDataConfig(app.value)
if (extractedData) data = extractedData
files = app.value.files
summary = app.summary
// lastVersion = app.version
@@ -158,6 +178,8 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// Brand-new raw app: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
// Suspend autosave across the bootstrap: the seed template and the
// picker's `onStart` are programmatic writes that must not POST as the
// first edit. Resume on first interaction (the template-card click or
@@ -259,6 +281,22 @@
// See /apps/edit's loader.
draftSavedAt = backendApp.draft_saved_at as string | undefined
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.
// `undefined` when there's no deployed row.
deployedBaseline = backendApp.no_deployed
? undefined
: (structuredClone(
stateSnapshot({
files: backendApp.value?.files ?? {},
runnables: backendApp.value?.runnables ?? {},
data: extractDataConfig(backendApp.value) ?? { ...DEFAULT_DATA },
summary: backendApp.summary ?? '',
policy: backendApp.policy,
custom_path: backendApp.custom_path
})
) as RawAppDraft)
// The raw-app autosave stores a flat `RawAppDraft`, but this loader (and
// `extractRawApp`) needs the deployed shape with `files`/`runnables`/`data`
// under `.value` and the rest top-level. Re-wrap the saved draft (`.draft`):
@@ -19,7 +19,7 @@
import { tick, untrack } from 'svelte'
import type { stepState } from '$lib/components/stepHistoryLoader.svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
import {
armRestartOnFirstInteraction,
discardDraftAfterDeploy,
@@ -43,6 +43,10 @@
}
let savedFlow: Flow | undefined = $state(undefined)
/** Deployed flow this load, the baseline the autosave `discardIf` compares
* against. `undefined` for draft-only paths (no deployed) so a draft-only
* item never self-destructs by "matching" a non-existent baseline. */
let deployedBaseline = $state<Flow | undefined>(undefined)
let otherDraftsUsers = $state<OtherDraftUser[]>([])
let loadedFromDraft = $state(false)
let othersModalOpen = $state(false)
@@ -65,7 +69,10 @@
const draftSync = usePageDraftSync<Flow>({
itemKind: 'flow',
path: () => flowDraftPath,
workspace: () => $workspaceStore
workspace: () => $workspaceStore,
// Autosaves landing back on the deployed flow become deletes, so reverting
// edits clears the draft instead of leaving a no-op behind.
discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline)
})
function emptyFlow(): Flow {
@@ -136,6 +143,8 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// Brand-new flow: no deployed baseline, so never discard-on-equal.
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
@@ -336,6 +345,11 @@
? ({ ...deployedFlow, ...draftFromBackend } as Flow)
: (deployedFlow as Flow)
savedFlow = structuredClone($state.snapshot(effectiveFlow)) as Flow
// 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.
@@ -17,7 +17,7 @@
import { get } from 'svelte/store'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
import { discardDraftAfterDeploy, runResetToDeployed } from '$lib/userDraftToast'
import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
@@ -65,11 +65,19 @@
// Page-level draft orchestration: autosave handle (re-keyed on nav via
// `draftPath`), live-editor-draft registry, `recordRemoteSync`, removal.
// `draftSync.draft` stays a stable lvalue for `bind:script`.
/** Deployed script this load (with `parent_hash` grafted to match the
* unedited draft seed), the baseline the autosave `discardIf` compares
* against. `undefined` for draft-only paths so they never self-destruct. */
let deployedBaseline = $state<EditableScript | undefined>(undefined)
const draftSync = usePageDraftSync<EditableScript>({
itemKind: 'script',
path: () => draftPath,
workspace: () => $workspaceStore,
effectivePath: () => draftSync.draft?.path ?? draftPath
effectivePath: () => draftSync.draft?.path ?? draftPath,
// Autosaves landing back on the deployed script become deletes, so
// reverting edits clears the draft instead of leaving a no-op behind.
discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline)
})
// Seed from the URL so ScriptBuilder mounts with a populated `initialPath`
@@ -129,6 +137,8 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
// Brand-new script: no deployed baseline, so never discard-on-equal.
deployedBaseline = undefined
// Capture every seeding param BEFORE stripping the URL flag.
const templatePath = page.url.searchParams.get('template')
const hubPath = page.url.searchParams.get('hub')
@@ -259,6 +269,9 @@
})
if (tok !== loadScriptToken) return
savedScript = structuredClone($state.snapshot(scriptByHash))
// Historical-hash view is read-only relative to drafts (`draftPath` is
// '' → detached handle), so no baseline is needed.
deployedBaseline = undefined
draftSync.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
} else {
const backendScript = await ScriptService.getScriptByPath({
@@ -292,6 +305,17 @@
? { ...deployedScript, ...draftFromBackend }
: (deployedScript as EditableScript)
savedScript = structuredClone($state.snapshot(effectiveScript))
// Baseline for the autosave `discardIf`: the deployed script with the
// same `parent_hash` graft the seed below applies, so the unedited
// draft compares equal. `undefined` when there's no deployed row.
deployedBaseline = backendScript.no_deployed
? undefined
: structuredClone(
$state.snapshot({
...deployedScript,
parent_hash: topHash ?? backendScript.hash
})
)
// `parent_hash` is grafted on so the editor's compile reuses the
// deployed lock. The first cell write after `acquireEntry` is swallowed
// by the syncer's seed guard, so this load doesn't POST.