fix: expand every dbt boundary a selection reaches, and only skip what was asked

Round 7's findings, all in the frontend seam this branch added:

- A ducklake selection seeded the dbt fetch from the FIRST boundary relation it
  found, so a table derived from two unconnected dbt relations expanded one and
  left the other a leaf — the same "stops at the boundary" symptom the round-6
  fix removed, one hop further along. Every distinct boundary is fetched now and
  the components merged.
- The component cache skipped a relation merely PRESENT in the graph in hand.
  A relation two projects describe has an owner row in each, and a component
  fetched for one carries it as an endpoint without the other's half, so that
  skipped the request that would have resolved the second owner. Only a relation
  actually asked about under this pin is skipped.
- A comment still called the producer graph gated to ducklake selections after
  it was widened to dbt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-05 09:35:37 +02:00
co-authored by Claude Opus 5
parent fcdcad1810
commit fab2b93299
3 changed files with 86 additions and 57 deletions
@@ -1,5 +1,10 @@
import { untrack } from 'svelte'
import { AssetService, JobService } from '$lib/gen'
import { buildDbtColumnGraph, type ColumnLineageGraph } from './columnLineageGraph'
import {
buildDbtColumnGraph,
mergeColumnGraphs,
type ColumnLineageGraph
} from './columnLineageGraph'
export const EMPTY_COLUMN_GRAPH: ColumnLineageGraph = {
nodes: new Map(),
@@ -21,7 +26,32 @@ export type DbtColumnLineageState = {
readonly loading: boolean
}
/** Follow the selection, fetching the selected dbt relation's column lineage.
function pinKey(workspace: string, pin: DbtGraphPin | undefined): string {
return `${workspace}|${pin?.jobId ?? ''}|${pin?.scriptHash ?? ''}`
}
function fetchComponent(
workspace: string,
assetPath: string,
pin: DbtGraphPin | undefined
): Promise<ColumnLineageGraph> {
const req = pin?.jobId
? JobService.getDbtRunColumnLineage({ workspace, id: pin.jobId, assetPath })
: AssetService.getDbtColumnLineage({
workspace,
assetPath,
dbtScriptHash: pin?.scriptHash != undefined ? String(pin.scriptHash) : undefined
})
return req.then(
(r) => buildDbtColumnGraph(r?.edges ?? []),
// Lineage annotates a graph that renders without it, so a failed fetch
// leaves that branch unexpanded rather than putting an error over the
// model — and one failed boundary does not lose the others.
() => EMPTY_COLUMN_GRAPH
)
}
/** Follow the selection, fetching the dbt column lineage it reaches.
*
* Per asset rather than off the graph response: the graph is folder-wide and a
* run page polls it, while this is drawn for one selection. It also means the
@@ -30,70 +60,62 @@ export type DbtColumnLineageState = {
*/
export function useDbtColumnLineage(args: {
workspace: () => string | undefined
/** The selected dbt relation, or undefined for any other selection. */
assetPath: () => string | undefined
/** The dbt relations to expand. The selection itself when it is one; for a
* selection of another kind, every dbt relation its own lineage reaches —
* a ducklake table can be derived from several, and expanding only the
* first would leave the rest as leaves. */
assetPaths: () => string[]
/** The graph on screen, so the lineage describes the same project. */
pin?: () => DbtGraphPin | undefined
}): DbtColumnLineageState {
let graph = $state<ColumnLineageGraph>(EMPTY_COLUMN_GRAPH)
let loading = $state(false)
// What the graph in hand describes, so a selection already inside it can be
// recognised without asking again.
let held: { workspace: string; pin: string } | undefined = undefined
// Which pin the graph in hand was fetched against, and which relations were
// actually ASKED about under it.
let heldPin: string | undefined = undefined
let asked = new Set<string>()
$effect(() => {
const workspace = args.workspace()
const assetPath = args.assetPath()
const paths = args.assetPaths()
const pin = args.pin?.()
const jobId = pin?.jobId
const scriptHash = pin?.scriptHash
if (!workspace || !assetPath) {
if (!workspace || paths.length === 0) {
graph = EMPTY_COLUMN_GRAPH
heldPin = undefined
asked = new Set()
loading = false
return
}
// The answer is one connected component, so every relation inside the one
// already held has the same answer — which is most clicks, since a
// project's models are connected by construction. Keyed to the graph the
// component was fetched against: the same relation under a different pin
// is a different project.
const key = `${workspace}|${jobId ?? ''}|${scriptHash ?? ''}`
if (held?.workspace === workspace && held.pin === key) {
for (const n of graph.nodes.values()) {
if (n.path === assetPath) {
loading = false
return
}
}
const key = pinKey(workspace, pin)
// `untrack`: this effect writes `graph`, so reading it as a dependency
// would make it retrigger itself forever.
const fresh = heldPin !== key
const base = untrack(() => (fresh ? EMPTY_COLUMN_GRAPH : graph))
if (fresh) asked = new Set()
// Only a relation this pin has ASKED about is skipped, not every relation
// present in what came back. A relation two projects describe has an owner
// row in each, and a component fetched for one of them carries that
// relation as an endpoint without the other project's half — so treating
// "appears in the graph" as "resolved" would hide exactly the cross-project
// edges the server's relation-keyed walk exists to merge.
const missing = paths.filter((p) => !asked.has(p))
if (missing.length === 0) {
graph = base
loading = false
return
}
// A selection changes faster than a request completes, so an answer is
// applied only while it is still the one being asked for.
let current = true
loading = true
const req = jobId
? JobService.getDbtRunColumnLineage({ workspace, id: jobId, assetPath })
: AssetService.getDbtColumnLineage({
workspace,
assetPath,
dbtScriptHash: scriptHash != undefined ? String(scriptHash) : undefined
})
req.then(
(r) => {
if (!current) return
graph = buildDbtColumnGraph(r?.edges ?? [])
held = { workspace, pin: key }
loading = false
},
() => {
// Lineage annotates a graph that renders without it, so a failed
// fetch shows no section rather than an error over the model.
if (!current) return
graph = EMPTY_COLUMN_GRAPH
held = undefined
loading = false
}
)
Promise.all(missing.map((p) => fetchComponent(workspace, p, pin))).then((parts) => {
if (!current) return
graph = mergeColumnGraphs(base, ...parts)
heldPin = key
for (const p of missing) asked.add(p)
loading = false
})
return () => {
current = false
}
@@ -224,7 +224,10 @@
// analysis pass has any, and it is drawn for one model at a time.
const columnLineage = useDbtColumnLineage({
workspace: () => opWs,
assetPath: () => (selectedDbt ? selectedAsset?.path : undefined),
assetPaths: () => {
const path = selectedDbt ? selectedAsset?.path : undefined
return path ? [path] : []
},
pin: () => selectionPin
})
// What the scripts around this project declare about its columns, off the
@@ -1990,9 +1990,9 @@
// (inferred + annotated) `column_lineage` and the asset write-edges. Drives
// the transitive column trace in the details pane. Built from `displayGraph`
// — the exact graph the canvas renders — so the trace matches it: draft
// overlays in edit / show-drafts, deployed-only in plain View. Gated to a
// ducklake selection so it isn't rebuilt on every editor keystroke when the
// trace UI isn't even shown.
// overlays in edit / show-drafts, deployed-only in plain View. Gated to the
// two asset kinds that can carry column lineage so it isn't rebuilt on every
// editor keystroke when the trace UI isn't even shown.
let producerColumnGraph = $derived(
pe.selection?.kind === 'asset' &&
(pe.selection.asset_kind === 'ducklake' || pe.selection.asset_kind === 'dbt')
@@ -2009,20 +2009,24 @@
// source — the boundary node above. Asking there is what lets a ducklake
// selection trace back up the dbt project that fed it, rather than stopping
// at the annotation.
let dbtSeedPath = $derived.by(() => {
let dbtSeedPaths = $derived.by(() => {
const sel = pe.selection
if (pe.activeDraft || sel?.kind !== 'asset') return undefined
if (sel.asset_kind === 'dbt') return sel.path
if (pe.activeDraft || sel?.kind !== 'asset') return []
if (sel.asset_kind === 'dbt') return [sel.path]
// EVERY dbt relation this selection reaches, not the first: one output can
// be derived from several, and expanding one would leave the others as
// leaves on the canvas.
const seeds = assetColumnNodes(producerColumnGraph, sel.asset_kind, sel.path)
const paths = new Set<string>()
for (const id of connectedComponent(seeds, producerColumnGraph)) {
const node = producerColumnGraph.nodes.get(id)
if (node?.kind === 'dbt') return node.path
if (node?.kind === 'dbt') paths.add(node.path)
}
return undefined
return [...paths]
})
const dbtColumnLineage = useDbtColumnLineage({
workspace: () => $workspaceStore,
assetPath: () => dbtSeedPath
assetPaths: () => dbtSeedPaths
})
// One graph across both, so a trace crosses the dbt/ducklake boundary in
// either direction rather than stopping at it.