fix: address review findings on trigger draft reconciliation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-07-29 20:18:18 +02:00
co-authored by Claude Opus 5
parent 20226a15b6
commit de14e6a1fb
23 changed files with 234 additions and 75 deletions
+4 -4
View File
@@ -865,11 +865,11 @@ async fn list_schedule(
// The `draft` row's key addresses the schedule; the value's `path` is
// only where it will deploy. Reporting the key as `path` is what lets
// a renamed draft still be opened, updated and deleted.
// The row key addresses the draft, so a value with no `path` yet (a
// half-filled editor) is still listable — same as the trigger handler.
let intended_path = v.get("path").and_then(|s| s.as_str()).unwrap_or("");
if intended_path.is_empty() {
continue;
}
let draft_path = (intended_path != row.path).then(|| intended_path.to_string());
let draft_path =
(!intended_path.is_empty() && intended_path != row.path).then(|| intended_path.to_string());
let path = row.path.clone();
let schedule = v
.get("schedule")
+3 -1
View File
@@ -672,7 +672,9 @@ async fn list_triggers<T: TriggerCrud>(
// only where it will deploy. Surfacing the key as `path` is what lets
// a renamed draft still be opened, updated and deleted.
if let Some(intended) = map.insert("path".into(), row.path.clone().into()) {
if intended.as_str() != Some(row.path.as_str()) {
let intended_str = intended.as_str().unwrap_or("");
// A half-filled editor may not have a path yet; that is not a rename.
if !intended_str.is_empty() && intended_str != row.path {
map.insert("draft_path".into(), intended);
}
}
@@ -1188,7 +1188,9 @@
selectedId && untrack(() => select(selectedId))
})
$effect.pre(() => {
initialPath && initialPath != '' && opWorkspace && untrack(() => loadTriggers())
// Not gated on `initialPath`: an undeployed flow has no deployed triggers
// but can already have drafted ones (loadTriggers falls back to $pathStore).
opWorkspace && untrack(() => loadTriggers())
})
$effect.pre(() => {
const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false
@@ -13,9 +13,14 @@
...restProps
} = $props()
function openScheduleEditor(isFlow: boolean) {
async function openScheduleEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
scheduleEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await scheduleEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
scheduleEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
@@ -309,18 +309,19 @@
})
export async function loadTriggers() {
if (!initialPath) {
return
// An undeployed script has no deployed triggers to count, but it can already
// have drafted ones — fetch against the path they were drafted at.
if (initialPath) {
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: opWorkspace!,
path: initialPath
})
}
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: opWorkspace!,
path: initialPath
})
await triggersState.fetchTriggers(
triggersCount,
opWorkspace,
initialPath,
initialPath || script.path,
false,
$primaryScheduleStore,
$userStore
@@ -1017,7 +1018,9 @@
}
}
$effect(() => {
initialPath != '' && untrack(() => loadTriggers())
// Not gated on `initialPath`: an undeployed script can already have drafted
// triggers (loadTriggers falls back to the live path).
untrack(() => loadTriggers())
})
let langs = $derived(
processLangs(script.language, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages))
@@ -2,7 +2,7 @@
import { userStore, workspaceStore } from '$lib/stores'
import { UserDraft } from '$lib/userDraft.svelte'
import FlowCard from '../flows/common/FlowCard.svelte'
import { getContext, onDestroy, createEventDispatcher } from 'svelte'
import { getContext, onDestroy, createEventDispatcher, tick } from 'svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import TriggersTable from './TriggersTable.svelte'
@@ -171,9 +171,10 @@
triggersState.deleteTrigger(triggersCount, triggerIndex)
triggersState.selectedTriggerIndex = triggersState.triggers.length - 1
// Let the panel unmount before dropping the row: its autosave still holds
// the config and would write it straight back as a fresh draft.
await tick()
// Drop the draft row too, else the trigger reappears on the next fetch.
// After the list update, so the editor panel's autosave has unmounted
// rather than re-persisting the config it still holds.
const draftKind = triggerDraftKind(trigger.type)
if (draftKind && trigger.path && $workspaceStore) {
UserDraft.remove(draftKind, trigger.path, { workspace: $workspaceStore })
@@ -55,6 +55,15 @@
onEmailDomain,
...props
}: Props = $props()
// `newTriggerSeed` seeds `openNew` and applies ONLY while the trigger is new.
// Once its row exists, passing anything here would short-circuit the editors'
// backend load (`loadTrigger` returns early on a `defaultConfig`) and show the
// seed instead of the saved draft — overwriting it on the next autosave.
const draftDefaults = $derived(
selectedTrigger.isNew
? selectedTrigger.newTriggerSeed
: (selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined)
)
</script>
{#if selectedTrigger.type === 'http'}
@@ -62,7 +71,7 @@
{selectedTrigger}
{isFlow}
path={initialPath || fakeInitialPath}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -90,7 +99,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{schema}
{customLabel}
{...props}
@@ -100,7 +109,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -109,7 +118,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -118,7 +127,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -127,7 +136,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -136,7 +145,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -145,7 +154,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -154,7 +163,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -163,7 +172,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -172,7 +181,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -181,7 +190,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{onEmailDomain}
{...props}
@@ -194,7 +203,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -204,7 +213,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -214,7 +223,7 @@
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
defaultValues={draftDefaults}
{customLabel}
{...props}
/>
@@ -19,7 +19,12 @@
async function openAmqpTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
amqpTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await amqpTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
amqpTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
@@ -19,7 +19,12 @@
async function openAzureTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
azureTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await azureTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
azureTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
@@ -31,7 +31,12 @@
async function openEmailTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
emailTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await emailTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
emailTriggerEditor?.openEdit(selectedTrigger.path ?? '', isFlow, defaultValues)
}
@@ -19,7 +19,12 @@
async function openGcpTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
gcpTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await gcpTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
gcpTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
@@ -18,7 +18,12 @@
async function openRouteEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
routeEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await routeEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
routeEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
@@ -20,7 +20,12 @@
async function openKafkaTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
kafkaTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await kafkaTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
kafkaTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
@@ -19,7 +19,12 @@
async function openMqttTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
mqttTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await mqttTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
mqttTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
@@ -19,7 +19,12 @@
async function openNatsTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
natsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await natsTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
natsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
@@ -21,7 +21,12 @@
async function openPostgresTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
postgresTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path }, newDraft)
await postgresTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path }, newDraft)
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
postgresTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
@@ -20,7 +20,12 @@
async function openSqsTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
sqsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await sqsTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
sqsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
@@ -34,6 +34,7 @@ import {
type Trigger,
type TriggerDraftTarget
} from './utils'
import { UserDraft } from '$lib/userDraft.svelte'
import { get, type Writable } from 'svelte/store'
import type { TriggerType } from './utils'
import type { UserExt } from '$lib/stores'
@@ -89,26 +90,29 @@ export class Triggers {
return $state.snapshot(this.#selectedTriggerIndex)
}
/** Add an undeployed trigger to the editor's list. For the draft-backed kinds
* this writes the `trigger_*` draft row up front draft rows are path-keyed,
* so the trigger needs a path before the user has typed one, and persisting
* immediately is what makes it survive a reload. Native kinds have no draft
* row yet and keep their config in `draftConfig` until deploy. */
addDraftTrigger(
/** Add an undeployed trigger to the editor's list.
*
* Draft rows are path-keyed, so the path is reserved here. Who writes the row
* depends on where the config comes from: a caller-supplied `seedCfg` is
* already complete (a scheduled poll, a trigger step's primary schedule), so
* it is written now and the trigger is deployable without ever opening its
* panel. Without one, only the editor's `openNew` can produce a complete
* config, so the trigger stays `isNew` until that autosave lands.
*
* Native kinds have no draft row at all and keep their config in
* `draftConfig` until they deploy. */
async addDraftTrigger(
triggersCountStore: Writable<TriggersCount | undefined>,
type: TriggerType,
target: TriggerDraftTarget,
seedCfg?: Record<string, any>
): number {
): Promise<number> {
const primaryScheduleExists = this.#triggers.some((t) => t.type === 'schedule' && t.isPrimary)
const isPrimary = type === 'schedule' && !primaryScheduleExists
const draftKind = triggerDraftKind(type)
const { runnablePath, isFlow, workspace } = target
// Reserve the path the draft row will be keyed at. The row itself is
// written by the editor panel's autosave, which is what turns the form's
// defaults into a complete config — seeding a partial one here would leave
// required fields unset and crash the editors that don't default them.
// Reserve the path the draft row is keyed at.
const path =
draftKind && workspace
? newDraftTriggerPath(
@@ -119,19 +123,30 @@ export class Triggers {
)
: undefined
// The runnable is carried explicitly: the editors otherwise fall back to the
// fake capture path an undeployed runnable is given.
const seed = { ...seedCfg, script_path: runnablePath, is_flow: isFlow }
// Only a caller-supplied config is complete enough to store; writing the
// bare seed would leave required fields unset for the editors that read a
// draft back without defaulting them.
const writeNow = !!draftKind && !!workspace && !!path && !!seedCfg
if (writeNow) {
UserDraft.save(draftKind!, path!, { ...seed, path }, { workspace })
await UserDraft.forcePersist(draftKind!, path!, { workspace })
}
const newTrigger: Trigger = {
id: generateRandomString(),
type,
path,
isPrimary,
isDraft: true,
isNew: true,
hasDraft: !!draftKind,
// Until the row exists this is the editor's starting point, whatever the
// kind — `TriggersWrapper` hands it to `openNew` as defaults. It carries
// the runnable explicitly: the editors otherwise fall back to the fake
// capture path an undeployed runnable is given.
draftConfig: { ...seedCfg, script_path: runnablePath, is_flow: isFlow }
isNew: !!draftKind && !writeNow,
hasDraft: writeNow,
newTriggerSeed: seed,
// Pending state, for the kinds with nowhere else to hold it.
draftConfig: draftKind ? undefined : seed
}
this.#triggers.push(newTrigger)
@@ -162,17 +177,21 @@ export class Triggers {
): number {
const currentTriggers = this.#triggers
// Preserve the editor-local config of the kinds that have no draft row
// (native); every other kind's pending state lives in the backend rows.
// (native); every other kind's pending state lives in the backend rows, and
// carrying anything local onto them would read as a permanent "Modified".
const configMap = new Map<string, Record<string, any>>(
currentTriggers
.filter((t) => t.type === type && t.draftConfig)
.filter((t) => t.type === type && t.draftConfig && !triggerDraftKind(t.type))
.map((t) => [t.path ?? '', t.draftConfig!])
)
const backendTriggers = remoteTriggers.map((trigger) => ({
type: type as TriggerType,
path: trigger.path,
isPrimary: type === 'schedule' && trigger.path === trigger.script_path,
// `path` is the draft row's key on a draft-only row; the schedule is the
// primary when the path it will DEPLOY to is the runnable's own.
isPrimary:
type === 'schedule' && (trigger.draft_path ?? trigger.path) === trigger.script_path,
// `draft_only` rows are synthesized from a draft with no deployed
// counterpart; `is_draft` also covers a draft layered on a deployed row.
isDraft: !!trigger.draft_only,
+16 -3
View File
@@ -112,8 +112,11 @@ export type Trigger = {
draftPath?: string
/** Added in this editor session and not yet written to the `draft` table. Its
* editor opens through `openNew`, the path that fills a complete config; the
* resulting autosave is what creates the row. */
* resulting autosave is what creates the row, after which this clears. */
isNew?: boolean
/** Starting values for that `openNew`. NOT pending state it is the defaults
* the form opens on, so it must not count as a change or reach a deploy. */
newTriggerSeed?: Record<string, any>
/** Editor-local unsaved config, for the trigger kinds that have no draft row
* yet (native only see `triggerDraftKind`). */
draftConfig?: Record<string, any>
@@ -438,11 +441,21 @@ export async function repointTriggerDrafts(
if (!kind || !trigger.path || !triggerHasPendingChanges(trigger)) return
const draft = await readTriggerDraft(kind, trigger.path, workspace)
if (!draft) return
if (draft.script_path === runnablePath && draft.is_flow === isFlow) return
// The primary schedule is the one deploying to the runnable's own path,
// so a rename moves where it deploys too — otherwise it stops reading as
// primary on the next reload and lands at the stale path.
const intendedPath = trigger.isPrimary ? runnablePath : draft.path
if (
draft.script_path === runnablePath &&
draft.is_flow === isFlow &&
draft.path === intendedPath
) {
return
}
UserDraft.save(
kind,
trigger.path,
{ ...draft, script_path: runnablePath, is_flow: isFlow },
{ ...draft, path: intendedPath, script_path: runnablePath, is_flow: isFlow },
{ workspace }
)
})
@@ -18,7 +18,12 @@
async function openWebsocketTriggerEditor(isFlow: boolean) {
if (selectedTrigger.isNew) {
wsTriggerEditor?.openNew(isFlow, path, { ...defaultValues, path: selectedTrigger.path })
await wsTriggerEditor?.openNew(isFlow, (selectedTrigger.newTriggerSeed?.script_path ?? path), { ...defaultValues, path: selectedTrigger.path })
// The autosave inside `openNew` created the draft row, so this trigger is
// no longer new: re-selecting it must reload that row, not reset the form
// to defaults and overwrite what the user just configured.
selectedTrigger.isNew = false
selectedTrigger.newTriggerSeed = undefined
} else {
wsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
+5 -5
View File
@@ -16,7 +16,7 @@
*
* Idempotent: the caller strips the field, so a converted draft has none.
*/
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte'
import { newDraftTriggerPath, triggerDraftKind, type Trigger } from '$lib/components/triggers/utils'
export async function migrateLegacyDraftTriggers(opts: {
@@ -24,12 +24,12 @@ export async function migrateLegacyDraftTriggers(opts: {
runnablePath: string
isFlow: boolean
workspace: string
}): Promise<boolean> {
}): Promise<{ kind: UserDraftItemKind; path: string }[]> {
const { legacy, runnablePath, isFlow, workspace } = opts
if (!Array.isArray(legacy) || legacy.length === 0) return false
if (!Array.isArray(legacy) || legacy.length === 0) return []
const taken: string[] = []
let migrated = false
const migrated: { kind: UserDraftItemKind; path: string }[] = []
for (const trigger of legacy) {
const cfg = trigger?.draftConfig
const kind = trigger?.type ? triggerDraftKind(trigger.type) : undefined
@@ -49,7 +49,7 @@ export async function migrateLegacyDraftTriggers(opts: {
{ workspace }
)
await UserDraft.forcePersist(kind, path, { workspace })
migrated = true
migrated.push({ kind, path })
}
return migrated
}
+36 -1
View File
@@ -43,6 +43,7 @@ import { classicAppDraftParts } from '$lib/appDiffSides'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { invalidateWorkspaceComparison } from '$lib/workspaceComparison'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { migrateLegacyDraftTriggers } from '$lib/legacyDraftTriggers'
import { userStore } from '$lib/stores'
import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils'
import { saveHttpRouteFromCfg } from '$lib/components/triggers/http/utils'
@@ -403,6 +404,24 @@ export async function fetchDraftBaseStale(
}
}
/** Deploy the trigger drafts just promoted out of a legacy `draft_triggers`
* array. Before triggers had their own rows they deployed alongside their
* runnable, so deploying the runnable must still land them rather than leave
* them behind as drafts. Failures are surfaced but don't fail the deploy the
* runnable is already live and the draft survives for a retry. */
async function deployMigratedTriggers(
migrated: { kind: DraftKind; path: string }[],
workspace: string
): Promise<void> {
for (const { kind, path } of migrated) {
const res = await deployDraft(kind, path, workspace, { draftOnly: true })
if (!res.success) {
console.error(`Could not deploy migrated ${kind} at ${path}: ${res.error}`)
}
}
}
/**
* Promote a draft to deployed by replaying the editor's create/update call with
* the stored draft value. The matching draft row is deleted server-side by the
@@ -428,10 +447,18 @@ export async function deployDraft(
const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any
const d = r.draft ?? r
// Drop editor-only / server-managed keys; deploy as a real (non-draft) version.
const { draft_triggers: _t, draft_only: _o, ...rest } = d
const { draft_triggers: legacyTriggers, draft_only: _o, ...rest } = d
const scriptPath = d.path ?? path
// Deploy at the draft's path so a rename in the draft is honored (same as
// the editor: createScript at the new path with parent_hash links lineage).
// Drafts written before triggers had their own rows carry them inline;
// promote them first — the create below deletes the draft they live in.
const migratedTriggers = await migrateLegacyDraftTriggers({
legacy: legacyTriggers,
runnablePath: scriptPath,
isFlow: false,
workspace
})
await ScriptService.createScript({
workspace,
requestBody: {
@@ -441,6 +468,7 @@ export async function deployDraft(
deployment_message: deploymentMessage
}
})
await deployMigratedTriggers(migratedTriggers, workspace)
} else if (kind === 'flow') {
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
const d = r.draft ?? r
@@ -462,6 +490,12 @@ export async function deployDraft(
labels: d.labels,
deployment_message: deploymentMessage
}
const migratedTriggers = await migrateLegacyDraftTriggers({
legacy: d.draft_triggers,
runnablePath: requestBody.path,
isFlow: true,
workspace
})
// Draft-only flows have NO flow row (they live solely in the
// draft table), so they deploy via createFlow; a draft on a
// deployed flow updates it.
@@ -470,6 +504,7 @@ export async function deployDraft(
} else {
await FlowService.updateFlow({ workspace, path, requestBody })
}
await deployMigratedTriggers(migratedTriggers, workspace)
} 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
@@ -28,6 +28,7 @@
discardDraftAfterDeploy,
runResetToDeployed
} from '$lib/userDraftToast'
import { migrateLegacyDraftTriggers } from '$lib/legacyDraftTriggers'
let version: undefined | number = $state(undefined)
@@ -424,6 +425,20 @@
draftSync.draft = flowToRender
}
// Drafts written before triggers had their own rows carry their trigger
// configs inline; promote them and drop the dead field.
const legacyTriggers = (flowToRender as any).draft_triggers
if (legacyTriggers && $workspaceStore) {
await migrateLegacyDraftTriggers({
legacy: legacyTriggers,
runnablePath: (flowToRender as any).draft_path ?? flowToRender.path,
isFlow: true,
workspace: $workspaceStore
})
const { draft_triggers: _legacy, ...rest } = draftSync.draft as any
draftSync.draft = rest
}
await initFlow(flow, flowStore, flowStateStore)
if (tok !== loadFlowToken) return
loading = false