fix: pipeline delete takes only its own triggers and discards draft-only nodes by storage key

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-01 13:54:19 +02:00
co-authored by Claude Opus 5.5
parent 6197cba556
commit a7a6a05e07
3 changed files with 69 additions and 9 deletions
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DraftService, ScriptService } from '$lib/gen'
import { planPipelineDelete } from './pipelineDelete'
afterEach(() => vi.restoreAllMocks())
describe('planPipelineDelete', () => {
it("takes only the pipeline's own triggers and addresses draft-only nodes by storage key", async () => {
vi.spyOn(ScriptService, 'listRunnables').mockResolvedValue({
items: [
{ type: 'script', path: 'f/sales/ingest', auto_kind: 'pipeline' },
{ type: 'script', path: 'f/sales/report' },
{
type: 'script',
path: 'u/alice/draft_abc',
draft_only: true,
draft_path: 'f/sales/unpublished',
auto_kind: 'pipeline'
}
]
} as any)
vi.spyOn(DraftService, 'getOwnDraft').mockResolvedValue(undefined as any)
const trigger = (runnable_kind: string, runnable_path: string, path: string) => ({
trigger_kind: 'schedule',
runnable_kind,
runnable_path,
path
})
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
triggers: [
trigger('script', 'f/sales/ingest', 'f/sales/ingest_schedule'),
trigger('script', 'f/sales/report', 'f/sales/report_schedule'),
trigger('flow', 'f/sales/ingest', 'f/sales/flow_schedule')
]
})
})
)
const plan = await planPipelineDelete('w', 'sales')
expect(plan.triggers).toEqual([{ kind: 'schedule', path: 'f/sales/ingest_schedule' }])
expect(plan.scripts).toEqual([
{ path: 'f/sales/ingest', displayPath: 'f/sales/ingest', draftOnly: false },
{ path: 'u/alice/draft_abc', displayPath: 'f/sales/unpublished', draftOnly: true }
])
vi.unstubAllGlobals()
})
})
@@ -26,7 +26,9 @@ async function listPipelineScripts(workspace: string, folder: string): Promise<R
return out
}
async function listNativeTriggers(workspace: string, folder: string) {
/** Trigger rows starting one of `scripts`. The folder graph also carries the
* triggers of the folder's ordinary scripts and flows, which are not the pipeline's. */
async function listNativeTriggers(workspace: string, folder: string, scripts: ReadonlySet<string>) {
const res = await fetch(
`${OpenAPI.BASE ?? ''}/w/${workspace}/assets/graph?${new URLSearchParams({ folder })}`,
{ credentials: 'include' }
@@ -35,6 +37,8 @@ async function listNativeTriggers(workspace: string, folder: string) {
const graph = (await res.json()) as AssetGraphResponse
return graph.triggers.flatMap((t) =>
t.trigger_kind !== 'asset' &&
t.runnable_kind === 'script' &&
scripts.has(t.runnable_path) &&
t.trigger_kind !== 'webhook' &&
t.trigger_kind !== 'data_upload' &&
t.path &&
@@ -45,7 +49,9 @@ async function listNativeTriggers(workspace: string, folder: string) {
}
export type PipelineDeletePlan = {
scripts: { path: string; draftOnly: boolean }[]
/** `path` is what the API addresses (a draft-only node's storage key);
* `displayPath` is the path the user typed for it. */
scripts: { path: string; displayPath: string; draftOnly: boolean }[]
triggers: { kind: NativeTriggerKind; path: string }[]
/** Whether the caller has an unsaved pipeline draft for this folder. */
hasDraft: boolean
@@ -56,9 +62,10 @@ export async function planPipelineDelete(
workspace: string,
folder: string
): Promise<PipelineDeletePlan> {
const [scripts, triggers, draft] = await Promise.all([
listPipelineScripts(workspace, folder),
listNativeTriggers(workspace, folder),
const scripts = await listPipelineScripts(workspace, folder)
const deployed = new Set(scripts.filter((s) => !s.draft_only).map((s) => s.path))
const [triggers, draft] = await Promise.all([
listNativeTriggers(workspace, folder, deployed),
DraftService.getOwnDraft({
workspace,
kind: PIPELINE_DRAFT_KIND,
@@ -67,7 +74,8 @@ export async function planPipelineDelete(
])
return {
scripts: scripts.map((s) => ({
path: s.draft_only ? s.draft_path || s.path : s.path,
path: s.path,
displayPath: (s.draft_only && s.draft_path) || s.path,
draftOnly: !!s.draft_only
})),
triggers,
@@ -110,7 +118,7 @@ export async function deletePipeline(
}
removed++
} catch (e) {
fail(s.path, e)
fail(s.displayPath, e)
}
}
if (plan.hasDraft) {
@@ -110,13 +110,13 @@
{#if plan.scripts.some((s) => !s.draftOnly)}
{@render group(
hardDelete ? 'Scripts deleted' : 'Scripts archived',
plan.scripts.filter((s) => !s.draftOnly).map((s) => s.path)
plan.scripts.filter((s) => !s.draftOnly).map((s) => s.displayPath)
)}
{/if}
{#if plan.scripts.some((s) => s.draftOnly)}
{@render group(
'Draft scripts discarded',
plan.scripts.filter((s) => s.draftOnly).map((s) => s.path)
plan.scripts.filter((s) => s.draftOnly).map((s) => s.displayPath)
)}
{/if}
{#if plan.triggers.length}