mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat(frontend): dedup user drafts against the deployed baseline (#9618)
This commit is contained in:
@@ -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?:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user