feat: a No asset node per script, banner-only when writes start it; no On asset change chip

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 19:35:46 +02:00
co-authored by Claude Opus 5.5
parent b621475c77
commit 2d72eeb765
5 changed files with 110 additions and 71 deletions
@@ -31,7 +31,6 @@
import {
assetsOnlyView,
upstreamDeletion,
NO_ASSET_NODE_ID,
withoutPassiveReads,
type AssetUpstream,
type AssetUpstreamDelete
@@ -578,7 +577,7 @@
if (!n) return id.replace(/^[^:]+:/, '')
if (n.type === 'asset')
return formatShortAssetPath({ kind: n.data.asset_kind, path: n.data.path })
if (n.type === 'no-asset') return 'the scripts that build no asset'
if (n.type === 'no-asset') return n.data.noAssetName
if (n.type === 'trigger') return n.data.summary || n.data.ref
return n.data.summary || n.data.path
}
@@ -1316,13 +1315,15 @@
scriptPath: string
): {
label: string
/** Nothing to show: the edges into the node already say it runs on writes. */
hidden?: boolean
nodeId?: string
missing?: boolean
draft?: boolean
onOpen?: () => void
} {
if (!t) return { label: 'Manual' }
if (t.kind === 'asset') return { label: 'On asset change' }
if (t.kind === 'asset') return { label: '', hidden: true }
const kind = t.kind as NativeTriggerKind
const style = TRIGGER_NODE_STYLE[kind]
const label =
@@ -1415,22 +1416,21 @@
]
const foldedErrors = foldedIds.filter((id) => nodeFixes?.has(id)).length
const asset = { kind: data.asset_kind, path: data.path }
const scriptCount = u?.multiple ? u.runnableIds.length : 1
// Chips this estimate does not model keep the regular width.
const width =
upstream && !data.dbt && !data.fork_materialization && !data.derived_from && !data.runStatus
? assetsOnlyNodeWidth({
kind: data.noAsset ? 'No asset' : formatAssetKind(asset),
title: data.noAsset
? `${scriptCount} script${scriptCount === 1 ? '' : 's'}`
: formatShortAssetPath(asset),
title: data.noAsset ? data.noAssetName : formatShortAssetPath(asset),
chip: foldedErrors
? `${foldedErrors} errors · Fix`
: 'none' in upstream
? 'External source'
: 'multiple' in upstream
? 'Multiple upstream nodes'
: `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`,
: upstream.trigger.hidden
? ''
: `${upstream.trigger.label}${upstream.trigger.draft ? ' · draft' : ''}`,
chipIcon: foldedErrors > 0,
chipEdit:
!foldedErrors &&
@@ -1489,12 +1489,22 @@
const nodes = m.nodes
.filter((n) => n.id === ADD_NODE_ID || v.nodeIds.has(n.id))
.map((n) => (n.type === 'asset' ? { ...n, data: fold(n.data, v.upstream.get(n.id)) } : n))
if (v.hasNoAssetNode) {
nodes.push({
id: NO_ASSET_NODE_ID,
type: 'no-asset',
data: fold({ noAsset: true }, v.upstream.get(NO_ASSET_NODE_ID))
})
for (const id of v.noAssetNodeIds) {
const u = v.upstream.get(id)
const scriptId = u && !u.multiple ? u.runnableId : u?.runnableIds[0]
const data = fold(
{ noAsset: true, noAssetName: scriptId?.split('/').pop() ?? 'script' },
u
)
// Run only by writes to what it reads, the edges into it already say how
// it starts: the script's banner alone. Another trigger, or an error to
// fix, needs the card's chip row.
const bannerOnly =
!!u &&
!u.multiple &&
u.trigger?.kind === 'asset' &&
!(data.foldedIds as string[]).some((f) => nodeFixes?.has(f))
nodes.push({ id, type: 'no-asset', data: bannerOnly ? { ...data, bannerOnly } : data })
}
const edges: BuiltEdge[] = v.edges.map((e) => ({ ...e, kind: e.kind as BuiltEdge['kind'] }))
if (nodes.some((n) => n.id === ADD_NODE_ID)) {
@@ -7,6 +7,7 @@
import NodeActionsMenu from './NodeActionsMenu.svelte'
import NodeFixButton, { type NodeFix } from './NodeFixButton.svelte'
import NodeOnDot from './NodeOnDot.svelte'
import { PIPELINE_NODE_HEADER } from './assetGraphLayout'
import AddDownstreamMenu from './AddDownstreamMenu.svelte'
import AddDownstreamPill from './AddDownstreamPill.svelte'
import {
@@ -68,6 +69,8 @@
runState?: RunnableRunState
trigger: {
label: string
/** No chip: the node's incoming edges already show what starts it. */
hidden?: boolean
missing?: boolean
draft?: boolean
/** Opens the trigger's editor; unset for what has none (manual, asset change). */
@@ -164,6 +167,10 @@
onStartOnDrag?: (e: PointerEvent) => void
/** The assets-only node for the scripts that build no asset. */
noAsset?: boolean
/** The No-asset node's script, by its file name. */
noAssetName?: string
/** Assets-only: the node is only its script's banner. */
bannerOnly?: boolean
/** No script in the workspace writes it; a flow's write fires no asset trigger. */
neverWritten?: boolean
/** Card width in the assets-only view, sized to its text. */
@@ -242,10 +249,6 @@
: []
)
let upstream = $derived(data.upstream)
let noAssetTitle = $derived.by(() => {
const n = upstream && 'scripts' in upstream ? upstream.scripts.length : 1
return `${n} script${n === 1 ? '' : 's'}`
})
const HEADER_SELECTED = 'bg-surface-accent-selected hover:bg-surface-accent-selected text-accent'
const CHIP_SELECTED =
'bg-surface-accent-selected border-border-selected hover:bg-surface-accent-selected text-accent'
@@ -465,19 +468,38 @@
<NodeActionsMenu
items={menuItems}
hover={hovered}
kebabClass={twMerge(showGuardBadge && '-right-8', hasHeader && CARD_CORNER_TOP)}
kebabClass={twMerge(
showGuardBadge && '-right-8',
hasHeader && !data.bannerOnly && CARD_CORNER_TOP
)}
>
{#if data.bannerOnly}
<!-- A script that builds nothing and runs only on writes to what it reads:
the incoming edges say how it starts, so its banner is the whole node. -->
<div
class={twMerge(
'flex items-stretch min-w-0 rounded-md overflow-hidden bg-surface border border-gray-200 dark:border-gray-700 drop-shadow-sm',
(producer ? producer.selected : coSelected) &&
'border-border-selected dark:border-border-selected'
)}
style="width: {data.width ?? 160}px; height: {PIPELINE_NODE_HEADER}px;"
>
{@render scriptHeader()}
</div>
{:else}
<PipelineNodeCard
kindLabel={data.noAsset ? 'No asset' : formatAssetKind(asset)}
title={data.noAsset ? noAssetTitle : formatShortAssetPath(asset)}
title={data.noAsset ? (data.noAssetName ?? '') : formatShortAssetPath(asset)}
tooltip={data.noAsset
? 'Scripts that build no asset'
? 'This script builds no asset'
: data.error
? `${data.path}: ${data.error}`
: data.path}
{selected}
tone={data.error ? 'error' : undefined}
subtitle={upstream ? upstreamRow : undefined}
subtitle={data.foldedFixes?.length || (upstream && !producer?.trigger?.hidden)
? upstreamRow
: undefined}
header={hasHeader ? scriptHeader : undefined}
headerSelected={producer ? !!producer.selected : coSelected}
width={data.width}
@@ -600,6 +622,7 @@
{/if}
{/snippet}
</PipelineNodeCard>
{/if}
</NodeActionsMenu>
{#if showGuardBadge}
<!-- Data-test outcome badge. Floats off the TOP-RIGHT corner (opposite the
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
assetsOnlyView,
upstreamDeletion,
NO_ASSET_NODE_ID,
noAssetNodeId,
withoutPassiveReads,
type AssetUpstream
} from './assetsOnlyView'
@@ -64,31 +64,31 @@ describe('assetsOnlyView', () => {
})
})
describe('the No asset node', () => {
it('gathers scripts that build nothing, but not dbt projects or macro libraries', () => {
describe('the No asset nodes', () => {
it('give each script that builds nothing its own node, fed by what it reads', () => {
const v = assetsOnlyView(
[
node('asset:ducklake:raw', 'asset'),
node('script:f/p/notify', 'runnable'),
node('asset:ducklake:a', 'asset'),
node('asset:ducklake:b', 'asset'),
node('script:f/p/on_a', 'runnable'),
node('script:f/p/on_b', 'runnable'),
node('script:f/p/dbt', 'runnable', { dbt: { model_count: 3 } }),
node('script:f/p/macros', 'runnable', { macros: [{ name: 'm' }] })
],
[edge('asset:ducklake:raw', 'script:f/p/notify', 'lineage-read')]
[
edge('asset:ducklake:a', 'script:f/p/on_a', 'trigger-asset'),
edge('asset:ducklake:b', 'script:f/p/on_b', 'trigger-asset')
]
)
expect(v.hasNoAssetNode).toBe(true)
expect(v.upstream.get(NO_ASSET_NODE_ID)).toMatchObject({ runnableId: 'script:f/p/notify' })
const onA = noAssetNodeId('script:f/p/on_a')
const onB = noAssetNodeId('script:f/p/on_b')
expect(v.noAssetNodeIds).toEqual([onA, onB])
expect(v.upstream.get(onA)).toMatchObject({ runnableId: 'script:f/p/on_a' })
expect(v.edges.map((e) => `${e.source} -> ${e.target}`)).toEqual([
`asset:ducklake:raw -> ${NO_ASSET_NODE_ID}`
`asset:ducklake:a -> ${onA}`,
`asset:ducklake:b -> ${onB}`
])
})
it('is absent when every script builds an asset', () => {
const v = assetsOnlyView(
[node('asset:ducklake:a', 'asset'), node('script:f/p/a', 'runnable')],
[edge('script:f/p/a', 'asset:ducklake:a', 'lineage-write')]
)
expect(v.hasNoAssetNode).toBe(false)
})
})
describe('upstreamDeletion', () => {
@@ -14,8 +14,10 @@ export type ViewEdge = {
reactive?: boolean
}
/** The assets-only node standing for the scripts that build no asset. */
export const NO_ASSET_NODE_ID = 'no-asset'
/** The assets-only node standing for a script that builds no asset: one per
* script, so scripts reacting to different inputs stay apart. */
export const noAssetNodeId = (runnableId: string) => `no-asset:${runnableId}`
export const isNoAssetNode = (id: string) => id.startsWith('no-asset:')
/** The script and trigger an assets-only asset's delete removes. */
export type AssetUpstreamDelete = {
@@ -49,8 +51,9 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
nodeIds: Set<string>
edges: Array<ViewEdge>
upstream: Map<string, AssetUpstream>
/** Some scripts build no asset; their upstream is under `NO_ASSET_NODE_ID`. */
hasNoAssetNode: boolean
/** A node per script that builds no asset (`noAssetNodeId`), its upstream
* under that id. */
noAssetNodeIds: string[]
} {
const nodeById = new Map(nodes.map((n) => [n.id, n]))
const inputsOf = new Map<string, E[]>()
@@ -129,43 +132,46 @@ export function assetsOnlyView<N extends ViewNode, E extends ViewEdge>(
upstream.set(assetId, describe([...new Set(writes.map((w) => w.source))]))
}
// Scripts that build no asset have nowhere to fold into, so they share one
// "No asset" node, fed by what they read. dbt projects (whose writes are not
// drawn) and macro libraries (which build nothing by design) stay out of it.
// Scripts that build no asset have nowhere to fold into, so each gets a "No
// asset" node, fed by what it reads. dbt projects (whose writes are not drawn)
// and macro libraries (which build nothing by design) get none.
const writers = new Set([...producersOf.values()].flat().map((w) => w.source))
const orphans = nodes
.filter(
(n) => n.type === 'runnable' && !writers.has(n.id) && !n.data?.dbt && !n.data?.macros?.length
)
.map((n) => n.id)
if (orphans.length > 0) {
upstream.set(NO_ASSET_NODE_ID, describe(orphans))
for (const id of orphans) {
for (const r of inputsOf.get(id) ?? []) {
const eid = `flow:${r.source}->${NO_ASSET_NODE_ID}`
const prev = out.get(eid)
const reactive = triggered.has(`${r.source}\n${id}`)
if (prev) {
prev.unsaved = prev.unsaved && !!r.unsaved
prev.reactive ||= reactive
if (!prev.via?.includes(id)) prev.via = [...(prev.via ?? []), id]
} else {
out.set(eid, {
id: eid,
source: r.source,
target: NO_ASSET_NODE_ID,
kind: 'asset-flow',
unsaved: !!r.unsaved,
reactive,
via: [id]
})
}
for (const id of orphans) {
const nodeId = noAssetNodeId(id)
upstream.set(nodeId, describe([id]))
for (const r of inputsOf.get(id) ?? []) {
const eid = `flow:${r.source}->${nodeId}`
const reactive = triggered.has(`${r.source}\n${id}`)
const prev = out.get(eid)
if (prev) {
prev.unsaved = prev.unsaved && !!r.unsaved
prev.reactive ||= reactive
} else {
out.set(eid, {
id: eid,
source: r.source,
target: nodeId,
kind: 'asset-flow',
unsaved: !!r.unsaved,
reactive,
via: [id]
})
}
}
}
const nodeIds = new Set(nodes.filter((n) => n.type === 'asset').map((n) => n.id))
return { nodeIds, edges: [...out.values()], upstream, hasNoAssetNode: orphans.length > 0 }
return {
nodeIds,
edges: [...out.values()],
upstream,
noAssetNodeIds: orphans.map(noAssetNodeId)
}
}
/** What deleting an asset's upstream removes: the script unless another asset
@@ -1,5 +1,5 @@
import { describeSchedule } from '$lib/utils/describeCron'
import { NO_ASSET_NODE_ID } from './assetsOnlyView'
import { isNoAssetNode } from './assetsOnlyView'
/** What a hovered edge says: "`subject` `phrase` `object`", then a note. */
export type EdgeDescription = { subject: string; phrase: string; object?: string; note?: string }
@@ -76,7 +76,7 @@ export function describeEdge(
? `Each write to ${src} reruns it.`
: `A write to ${src} does not rerun it.`
// The scripts that build nothing only read: name them, not the node.
if (e.target === NO_ASSET_NODE_ID) {
if (isNoAssetNode(e.target)) {
return { subject: src, phrase: 'is read by', object: via || tgt, note: rerun }
}
return {