mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1031 lines
40 KiB
TypeScript
1031 lines
40 KiB
TypeScript
import type {
|
|
AssetGraphMacroEdge,
|
|
AssetGraphResponse,
|
|
NativeTriggerKind,
|
|
PipelineTriggerDraft
|
|
} from './types'
|
|
import {
|
|
mergeColumnLineage,
|
|
parsePipelineAnnotations,
|
|
scd2CurrentTargetPath,
|
|
type ColumnLineage,
|
|
type PipelineAnnotations
|
|
} from './parsePipelineAnnotations'
|
|
import {
|
|
extractWrites,
|
|
extractReads,
|
|
type AssetKind,
|
|
type AssetWithAltAccessType
|
|
} from '$lib/components/assets/lib'
|
|
|
|
/** Minimal structural shape of a pipeline draft `resolveGraph` needs. */
|
|
export type GraphDraft = {
|
|
script: { content: string }
|
|
outputAssets?: Array<{ kind: AssetKind; path: string }>
|
|
/** Reads captured on pane teardown. `undefined` = not captured (legacy
|
|
* bundle) → fall back to the session cache; `[]` = authoritative none. */
|
|
inputAssets?: Array<{ kind: AssetKind; path: string }>
|
|
}
|
|
|
|
export type ResolveGraphInput = {
|
|
/** Persisted graph from the backend (`/assets/graph`). */
|
|
base: AssetGraphResponse
|
|
/** In-flight drafts keyed by script path. */
|
|
drafts: Map<string, GraphDraft>
|
|
/** Body assets inferred for the currently-open script (live keystrokes). */
|
|
liveBodyAssets: {
|
|
scriptPath: string | undefined
|
|
assets: AssetWithAltAccessType[]
|
|
/** Body-inferred column lineage (DuckDB SQL AST) for the open script. */
|
|
columnLineage?: ColumnLineage[]
|
|
}
|
|
/** Pipeline annotations parsed from the currently-open buffer. */
|
|
liveAnnotations: { scriptPath: string | undefined; annotations: PipelineAnnotations }
|
|
/** Sticky session caches of inferred body writes/reads per script path. */
|
|
inferredWritesByPath: Map<string, Array<{ kind: AssetKind; path: string }>>
|
|
inferredReadsByPath: Map<string, Array<{ kind: AssetKind; path: string }>>
|
|
/**
|
|
* Sticky cache of native trigger kinds declared via `// on <kind>` in
|
|
* each script's deployed source. Filled by the load-time prefetch
|
|
* sweep. Used here to emit "missing" placeholders for scripts whose
|
|
* annotation has no matching trigger row in `base.triggers`.
|
|
*/
|
|
annotatedNativeKindsByPath: Map<string, Set<NativeTriggerKind>>
|
|
/** Undeployed triggers; each stands in for its script's missing trigger of that kind. */
|
|
triggerDrafts?: Iterable<PipelineTriggerDraft>
|
|
}
|
|
|
|
/** Mutable bag the sequential passes accumulate the resolved graph into. */
|
|
type Accumulator = {
|
|
runnables: AssetGraphResponse['runnables']
|
|
assets: AssetGraphResponse['assets']
|
|
edges: AssetGraphResponse['edges']
|
|
extraTriggers: AssetGraphResponse['triggers']
|
|
}
|
|
|
|
/**
|
|
* Native trigger kinds with a persisted (non-asset) trigger row pointing at
|
|
* `path`. These bind by script path and survive content edits, so they dedup
|
|
* against draft/live annotations to avoid showing a real source node next to a
|
|
* red "missing" placeholder for the same kind.
|
|
*/
|
|
function persistedNativeKinds(base: AssetGraphResponse, path: string): Set<string> {
|
|
return new Set(
|
|
base.triggers
|
|
.filter(
|
|
(t) =>
|
|
t.trigger_kind !== 'asset' && t.runnable_kind === 'script' && t.runnable_path === path
|
|
)
|
|
.map((t) => t.trigger_kind)
|
|
)
|
|
}
|
|
|
|
// Read-asset kinds that auto-derive a cascade trigger edge inside a
|
|
// `// pipeline`. Mirror of the backend `is_auto_trigger_kind`
|
|
// (windmill-common assets.rs): ducklake tables and s3 objects.
|
|
//
|
|
// `dbt` is excluded there and must stay excluded here: a warehouse relation is
|
|
// usually built by the dbt project that reads it, and a dbt run does not
|
|
// dispatch, so deriving an edge from every `dbt://` read would draw cascade
|
|
// arrows that mostly never fire. An EXPLICIT `// on dbt://…` is drawn — the
|
|
// relations a native `// materialize manual dbt://…` script writes do wake
|
|
// subscribers, and the deploy is what refuses the case that cannot (a relation
|
|
// dbt alone builds; `sole_dbt_producer` in windmill-common assets.rs).
|
|
const AUTO_TRIGGER_KINDS: ReadonlySet<AssetKind> = new Set(['ducklake', 's3object'])
|
|
|
|
/** `kind:path` refs of a script's `// materialize` write target(s) (base +
|
|
* the scd2 `<dim>_current` companion), which the body `SELECT` doesn't express. */
|
|
function materializeWriteRefs(parsed: PipelineAnnotations): string[] {
|
|
const m = parsed.materialize
|
|
if (!m) return []
|
|
const refs = [`${m.targetKind}:${m.targetPath}`]
|
|
const current = scd2CurrentTargetPath(m)
|
|
if (current) refs.push(`${m.targetKind}:${current}`)
|
|
return refs
|
|
}
|
|
|
|
/**
|
|
* Backend-mirror of `derive_pipeline_asset_trigger_refs` (windmill-common
|
|
* assets.rs): a pipeline script's ducklake/s3 read auto-wires a cascade
|
|
* trigger edge from the FROM clause, so `// on <asset>` is only needed for
|
|
* edges inference can't see. Excluded: assets the script also writes (`writes`
|
|
* covers `w`/`rw`, so an `rw` self-read can't loop-trigger), muted assets,
|
|
* `// mute all`, and any explicit `// on` (which already emits its own edge).
|
|
* Returns the `{kind, path}` refs to overlay as unsaved asset triggers, deduped.
|
|
*/
|
|
function deriveAutoAssetTriggers(
|
|
reads: Array<{ kind: AssetKind; path: string }>,
|
|
writes: Array<{ kind: AssetKind; path: string }>,
|
|
parsed: PipelineAnnotations
|
|
): Array<{ kind: AssetKind; path: string }> {
|
|
// Auto-derivation is scoped to `// pipeline` scripts (backend gates on
|
|
// `in_pipeline`); `// mute all` opts a pipeline script back out.
|
|
if (!parsed.inPipeline || parsed.muteAll) return []
|
|
const skip = new Set<string>([
|
|
...writes.map((w) => `${w.kind}:${w.path}`),
|
|
// The `// materialize` target(s) are this script's own output — the body
|
|
// `SELECT` doesn't express the write, so an incremental model that reads
|
|
// its own target would otherwise auto-derive a self-cascade edge (backend
|
|
// parity: the deploy path upgrades the same read to `rw`).
|
|
...materializeWriteRefs(parsed),
|
|
...parsed.muteAssets.map((a) => `${a.kind}:${a.path}`),
|
|
...parsed.triggerAssets.map((a) => `${a.kind}:${a.path}`)
|
|
])
|
|
const out: Array<{ kind: AssetKind; path: string }> = []
|
|
for (const r of reads) {
|
|
const key = `${r.kind}:${r.path}`
|
|
if (!AUTO_TRIGGER_KINDS.has(r.kind) || skip.has(key)) continue
|
|
skip.add(key) // dedup within reads too
|
|
out.push({ kind: r.kind, path: r.path })
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Edge key matching a lineage/trigger edge's `(asset, runnable)` pair. */
|
|
function edgeKey(a: {
|
|
asset_kind: string
|
|
asset_path: string
|
|
runnable_kind: string
|
|
runnable_path: string
|
|
}): string {
|
|
return `${a.asset_kind}:${a.asset_path}->${a.runnable_kind}:${a.runnable_path}`
|
|
}
|
|
|
|
/**
|
|
* Keys of "muted read" edges: a ducklake/s3 asset a script reads read-*only*
|
|
* (`access_type === 'r'`) yet has NO cascade trigger for. Auto-derivation is the
|
|
* default, so a supported read with no trigger means the author opted it out
|
|
* with `// mute <asset>` / `// mute all` (or it's an explicit non-triggering
|
|
* read). The canvas badges these — the exception — instead of every derived
|
|
* edge. Self-reads are excluded: a script that also writes the asset carries
|
|
* `'rw'` on the deployed graph (one edge) or a separate `'w'` edge in the live
|
|
* overlay (a `// materialize` producer reading its own target), and neither
|
|
* should badge as muted — it's the script's own output, not a suppressed input.
|
|
*
|
|
* Only `// pipeline` scripts are considered: auto-derivation never applies to a
|
|
* plain script or a flow, so a non-pipeline runnable reading a ducklake/s3
|
|
* asset has no auto trigger to suppress and must render as ordinary lineage.
|
|
*/
|
|
export function computeMutedReadKeys(
|
|
edges: AssetGraphResponse['edges'],
|
|
triggers: AssetGraphResponse['triggers'],
|
|
runnables: AssetGraphResponse['runnables']
|
|
): Set<string> {
|
|
const pipelineScripts = new Set(
|
|
runnables.filter((r) => r.usage_kind === 'script' && r.in_pipeline).map((r) => r.path)
|
|
)
|
|
const cascaded = new Set(
|
|
triggers.filter((t) => t.trigger_kind === 'asset').map((t) => edgeKey(t))
|
|
)
|
|
const written = new Set(
|
|
edges.filter((e) => e.access_type === 'w' || e.access_type === 'rw').map((e) => edgeKey(e))
|
|
)
|
|
const muted = new Set<string>()
|
|
for (const e of edges) {
|
|
if (e.access_type !== 'r' || !AUTO_TRIGGER_KINDS.has(e.asset_kind)) continue
|
|
if (e.runnable_kind !== 'script' || !pipelineScripts.has(e.runnable_path)) continue
|
|
const key = edgeKey(e)
|
|
if (!cascaded.has(key) && !written.has(key)) muted.add(key)
|
|
}
|
|
return muted
|
|
}
|
|
|
|
/** `kind:path` keys of persisted asset (`// on <asset>`) triggers for `path`. */
|
|
function persistedAssetKeys(base: AssetGraphResponse, path: string): Set<string> {
|
|
return new Set(
|
|
base.triggers
|
|
.filter(
|
|
(t) =>
|
|
t.trigger_kind === 'asset' && t.runnable_kind === 'script' && t.runnable_path === path
|
|
)
|
|
.map((t) => (t.trigger_kind === 'asset' ? `${t.asset_kind}:${t.asset_path}` : ''))
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Emit a red "missing" placeholder for each annotated native kind that has no
|
|
* matching attached trigger row. `opts.unsaved` marks editor-driven overlays
|
|
* (drafts / live buffer); deployed-but-unswept scripts pass `unsaved: false`.
|
|
*/
|
|
function pushMissingNativeTriggers(
|
|
extraTriggers: AssetGraphResponse['triggers'],
|
|
annotatedKinds: Iterable<NativeTriggerKind>,
|
|
attachedKinds: Set<string>,
|
|
path: string,
|
|
opts: { unsaved: boolean }
|
|
) {
|
|
for (const kind of annotatedKinds) {
|
|
if (attachedKinds.has(kind)) continue
|
|
extraTriggers.push({
|
|
trigger_kind: kind,
|
|
runnable_kind: 'script',
|
|
runnable_path: path,
|
|
...(opts.unsaved ? { unsaved: true } : {}),
|
|
missing: true
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge the persisted base graph with the draft, session-inferred and
|
|
* open-script live overlays into one `AssetGraphResponse`.
|
|
*
|
|
* Precedence (lowest → highest): base < session-inferred (read/write
|
|
* lineage) < per-draft seeded triggers/outputs < live annotations for the
|
|
* one currently-open script (which replace that path's seeded triggers).
|
|
* Overlay edges are tagged `unsaved` and deduped against persisted edges of
|
|
* the matching access direction.
|
|
*
|
|
* Pure (no Svelte reactivity) so the precedence matrix is unit-testable in
|
|
* isolation — extracted verbatim from the pipeline page's former
|
|
* `graphWithDraft` `$derived`. See `resolveGraph.test.ts`.
|
|
*/
|
|
export function resolveGraph(input: ResolveGraphInput): AssetGraphResponse {
|
|
const { base } = input
|
|
const ctx = makeContext(input)
|
|
|
|
// Pass order mirrors the precedence comment, lowest → highest:
|
|
const acc = seedAccumulator(input, ctx)
|
|
seedDraftOverlays(acc, input)
|
|
applyLiveBufferOverlay(acc, input, ctx)
|
|
crossCheckSweptScripts(acc, input)
|
|
overlayInferredLineage(acc, input)
|
|
|
|
// Drop persisted ASSET triggers for drafted paths — those come from the
|
|
// deployed `// on <asset>` annotations, which the draft's live/parsed
|
|
// annotations now own. Native triggers (kafka/schedule/…) are kept: they
|
|
// bind by `script_path`, which the draft shares, so the attachment is still
|
|
// valid regardless of content edits.
|
|
const baseTriggers = base.triggers.filter((t) => {
|
|
if (t.trigger_kind !== 'asset') return true
|
|
if (ctx.isDrafted(t.runnable_kind, t.runnable_path)) return false
|
|
if (
|
|
t.runnable_kind === 'script' &&
|
|
t.runnable_path === ctx.openPath &&
|
|
ctx.staleForOpen(t.asset_kind, t.asset_path)
|
|
)
|
|
return false
|
|
return true
|
|
})
|
|
// Mirror the backend's skip-if-empty: no `macro_edges` key at all when
|
|
// there is nothing to show (also keeps the no-macros response shape
|
|
// byte-identical to before the feature).
|
|
const macroEdges = resolveMacroEdges(input)
|
|
const triggers = withTriggerDrafts(
|
|
[...baseTriggers, ...acc.extraTriggers],
|
|
input.triggerDrafts ?? []
|
|
)
|
|
return {
|
|
...base,
|
|
assets: dropOrphanedAssets(acc.assets, base, acc.edges, triggers),
|
|
runnables: acc.runnables,
|
|
edges: acc.edges,
|
|
triggers,
|
|
...(macroEdges.length > 0 || base.macro_edges ? { macro_edges: macroEdges } : {})
|
|
}
|
|
}
|
|
|
|
function withTriggerDrafts(
|
|
triggers: AssetGraphResponse['triggers'],
|
|
triggerDrafts: Iterable<PipelineTriggerDraft>
|
|
): AssetGraphResponse['triggers'] {
|
|
const drafts = [...triggerDrafts]
|
|
if (drafts.length === 0) return triggers
|
|
const drafted = new Set(drafts.map((d) => `${d.kind}:${d.config.script_path}`))
|
|
return [
|
|
...triggers.filter(
|
|
(t) =>
|
|
!(
|
|
t.trigger_kind !== 'asset' &&
|
|
t.missing &&
|
|
t.runnable_kind === 'script' &&
|
|
drafted.has(`${t.trigger_kind}:${t.runnable_path}`)
|
|
)
|
|
),
|
|
...drafts.map((d) => ({
|
|
trigger_kind: d.kind,
|
|
path: d.config.path,
|
|
runnable_kind: 'script' as const,
|
|
runnable_path: d.config.script_path,
|
|
unsaved: true,
|
|
draft: true
|
|
}))
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Nodes the pipeline cannot work with, with the reason. Assets (keyed
|
|
* `kind:path`): one nothing uses at all, and one a script subscribes to
|
|
* (`// on <asset>`) that no runnable in the graph writes. Scripts (keyed by
|
|
* path): the subscriber, whose subscription can never fire. A body read of an
|
|
* external asset is fine and is not passed here: only the explicit `// on`
|
|
* annotations, per script path, count as subscriptions.
|
|
*/
|
|
export function pipelineNodeErrors(
|
|
graph: AssetGraphResponse,
|
|
explicitOnByPath: Map<string, Array<{ kind: AssetKind; path: string }>>
|
|
): { assets: Map<string, string>; scripts: Map<string, string> } {
|
|
const referenced = referencedAssetKeys(graph)
|
|
const written = new Set(
|
|
graph.edges
|
|
.filter((e) => e.access_type === 'w' || e.access_type === 'rw')
|
|
.map((e) => `${e.asset_kind}:${e.asset_path}`)
|
|
)
|
|
// The dbt project's own edges are hidden from the pipeline graph (see
|
|
// `hideDbtRunnables`), so its relations are judged by their provenance: the
|
|
// project uses every one, and materializes all but its sources.
|
|
for (const a of graph.assets) {
|
|
if (!a.dbt) continue
|
|
referenced.add(`${a.kind}:${a.path}`)
|
|
if (a.dbt.resource_type !== 'source') written.add(`${a.kind}:${a.path}`)
|
|
}
|
|
const errors = new Map<string, string>()
|
|
const scriptErrors = new Map<string, string>()
|
|
for (const a of graph.assets) {
|
|
const key = `${a.kind}:${a.path}`
|
|
if (!referenced.has(key)) errors.set(key, 'nothing in the pipeline uses it')
|
|
}
|
|
const scripts = new Set(
|
|
graph.runnables.filter((r) => r.usage_kind === 'script').map((r) => r.path)
|
|
)
|
|
for (const [path, refs] of explicitOnByPath) {
|
|
if (!scripts.has(path)) continue
|
|
for (const ref of refs) {
|
|
const key = `${ref.kind}:${ref.path}`
|
|
if (written.has(key)) continue
|
|
if (!errors.has(key)) {
|
|
errors.set(key, `${path} runs on it, but nothing in the pipeline writes it`)
|
|
}
|
|
if (!scriptErrors.has(path)) {
|
|
scriptErrors.set(path, `runs on ${ref.path}, which nothing in the pipeline writes`)
|
|
}
|
|
}
|
|
}
|
|
return { assets: errors, scripts: scriptErrors }
|
|
}
|
|
|
|
function referencedAssetKeys(
|
|
graph: Pick<AssetGraphResponse, 'edges' | 'triggers' | 'test_edges' | 'dbt_edges'>
|
|
): Set<string> {
|
|
const keys = new Set<string>()
|
|
for (const e of graph.edges) keys.add(`${e.asset_kind}:${e.asset_path}`)
|
|
for (const t of graph.triggers)
|
|
if (t.trigger_kind === 'asset') keys.add(`${t.asset_kind}:${t.asset_path}`)
|
|
for (const e of graph.test_edges ?? []) keys.add(`${e.asset_kind}:${e.asset_path}`)
|
|
for (const e of graph.dbt_edges ?? []) {
|
|
keys.add(`dbt:${e.from_asset_path}`)
|
|
keys.add(`dbt:${e.to_asset_path}`)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
/**
|
|
* The backend only returns assets something references, so a deployed asset
|
|
* whose every edge and trigger the draft/live overlays stripped — an output the
|
|
* open script renamed — would linger as an edgeless node until the deploy
|
|
* lands. Assets that were already unreferenced in `base` are left alone.
|
|
*/
|
|
function dropOrphanedAssets(
|
|
assets: AssetGraphResponse['assets'],
|
|
base: AssetGraphResponse,
|
|
edges: AssetGraphResponse['edges'],
|
|
triggers: AssetGraphResponse['triggers']
|
|
): AssetGraphResponse['assets'] {
|
|
const before = referencedAssetKeys(base)
|
|
const after = referencedAssetKeys({ ...base, edges, triggers })
|
|
return assets.filter((a) => {
|
|
const key = `${a.kind}:${a.path}`
|
|
return !before.has(key) || after.has(key)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Macro-library → consumer edges: the deployed base edges, with `// use`
|
|
* declarations of overlaid scripts (drafts + the open buffer) taking over
|
|
* their consumer's `via_use` edges so adding/removing a `// use` line updates
|
|
* the canvas live. Detection-based edges (macro calls in the deployed body)
|
|
* are backend-owned and only refresh on redeploy.
|
|
*/
|
|
function resolveMacroEdges(input: ResolveGraphInput): AssetGraphMacroEdge[] {
|
|
const { base, drafts, liveAnnotations } = input
|
|
const libMacroNames = new Map<string, string[]>()
|
|
for (const r of base.runnables) {
|
|
if (r.usage_kind === 'script' && r.macros?.length) {
|
|
libMacroNames.set(
|
|
r.path,
|
|
r.macros.map((m) => m.name)
|
|
)
|
|
}
|
|
}
|
|
const useByPath = new Map<string, string[]>()
|
|
for (const [path, d] of drafts) {
|
|
useByPath.set(path, parsePipelineAnnotations(d.script.content).useLibs)
|
|
}
|
|
if (liveAnnotations.scriptPath) {
|
|
// `?? []` — callers may hand a minimal annotations object (tests, older
|
|
// call sites) that predates the field.
|
|
useByPath.set(liveAnnotations.scriptPath, liveAnnotations.annotations.useLibs ?? [])
|
|
}
|
|
const out: AssetGraphMacroEdge[] = []
|
|
for (const e of base.macro_edges ?? []) {
|
|
if (e.via_use && useByPath.has(e.consumer_path)) continue
|
|
out.push({ ...e })
|
|
}
|
|
for (const [path, libs] of useByPath) {
|
|
for (const lib of libs) {
|
|
const existing = out.find((e) => e.lib_path === lib && e.consumer_path === path)
|
|
if (existing) {
|
|
// Upgrade the detection edge in place: `// use` pulls in the whole
|
|
// library, so the edge covers every macro the lib defines.
|
|
existing.via_use = true
|
|
existing.unsaved = true
|
|
existing.macro_names = [
|
|
...new Set([...existing.macro_names, ...(libMacroNames.get(lib) ?? [])])
|
|
]
|
|
} else {
|
|
out.push({
|
|
lib_path: lib,
|
|
consumer_path: path,
|
|
macro_names: libMacroNames.get(lib) ?? [],
|
|
via_use: true,
|
|
unsaved: true
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Light regex extraction of a draft macro library's definitions for the live
|
|
// node badge. The strict grammar lives in the Rust `parse_macro_library` at
|
|
// deploy; this only needs name/params/table-ness for display (nested parens
|
|
// in a default value may truncate the shown signature, never the deploy).
|
|
const MACRO_DEF_RE =
|
|
/create\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?(?:macro|function)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)\s*as\s+(table\b)?/gi
|
|
|
|
export function extractDraftMacros(
|
|
content: string
|
|
): { name: string; params: string; is_table: boolean }[] {
|
|
const out: { name: string; params: string; is_table: boolean }[] = []
|
|
for (const m of content.matchAll(MACRO_DEF_RE)) {
|
|
out.push({ name: m[1].toLowerCase(), params: m[2].trim(), is_table: m[3] !== undefined })
|
|
}
|
|
return out
|
|
}
|
|
|
|
type ResolveContext = {
|
|
draftedPaths: Set<string>
|
|
isDrafted: (kind: string, p: string) => boolean
|
|
openPath: string | undefined
|
|
staleForOpen: (kind: AssetKind, path: string) => boolean
|
|
}
|
|
|
|
/** Derive the shared "what's drafted / open / stale" predicates once. */
|
|
function makeContext(input: ResolveGraphInput): ResolveContext {
|
|
const { drafts, liveBodyAssets, liveAnnotations } = input
|
|
|
|
// A drafted path's content-derived lineage is owned entirely by the draft
|
|
// overlay (live inference for the active draft, parsed annotations /
|
|
// snapshot for inactive ones). Drop persisted base asset edges for those
|
|
// paths so it shows only the draft's I/O, not the union of the saved version
|
|
// and the in-flight edits. (Base asset triggers are dropped at the return;
|
|
// native triggers are kept — they bind by path.)
|
|
const draftedPaths = new Set(drafts.keys())
|
|
const isDrafted = (kind: string, p: string) => kind === 'script' && draftedPaths.has(p)
|
|
|
|
const openPath = liveBodyAssets.scriptPath
|
|
const openIsSavedEdit = openPath !== undefined && !draftedPaths.has(openPath)
|
|
const liveRefKeys = new Set<string>()
|
|
if (openIsSavedEdit) {
|
|
if (liveAnnotations.scriptPath === openPath) {
|
|
for (const a of liveAnnotations.annotations.triggerAssets)
|
|
liveRefKeys.add(`${a.kind}:${a.path}`)
|
|
// The `// materialize <asset>` target is a declared *output*, but it
|
|
// lives in an annotation (not the SQL body), so neither triggerAssets
|
|
// (inputs) nor the body-inferred assets cover it. Without this its
|
|
// persisted write-edge is judged stale and dropped the moment the
|
|
// script is selected/edited — leaving the output asset unlinked. A
|
|
// managed scd2 producer persists a second write to the `<dim>_current`
|
|
// companion view, so keep that too or a consumer of only the view
|
|
// orphans while the producer is open for editing.
|
|
const m = liveAnnotations.annotations.materialize
|
|
if (m) {
|
|
liveRefKeys.add(`${m.targetKind}:${m.targetPath}`)
|
|
const currentPath = scd2CurrentTargetPath(m)
|
|
if (currentPath) liveRefKeys.add(`${m.targetKind}:${currentPath}`)
|
|
}
|
|
}
|
|
for (const a of liveBodyAssets.assets) liveRefKeys.add(`${a.kind}:${a.path}`)
|
|
}
|
|
const staleForOpen = (kind: AssetKind, path: string) =>
|
|
openIsSavedEdit && !liveRefKeys.has(`${kind}:${path}`)
|
|
|
|
return { draftedPaths, isDrafted, openPath, staleForOpen }
|
|
}
|
|
|
|
/** Base graph plus the persisted edges that survive the draft/open filter. */
|
|
function seedAccumulator(input: ResolveGraphInput, ctx: ResolveContext): Accumulator {
|
|
const { base } = input
|
|
const edges = base.edges.filter((e) => {
|
|
if (ctx.isDrafted(e.runnable_kind, e.runnable_path)) return false
|
|
if (
|
|
e.runnable_kind === 'script' &&
|
|
e.runnable_path === ctx.openPath &&
|
|
ctx.staleForOpen(e.asset_kind, e.asset_path)
|
|
)
|
|
return false
|
|
return true
|
|
})
|
|
return {
|
|
runnables: [...base.runnables],
|
|
assets: [...base.assets],
|
|
edges,
|
|
extraTriggers: []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The write output(s) a `// materialize <asset>` annotation declares: the
|
|
* target itself, plus — for a managed scd2 materialize — the `<dim>_current`
|
|
* companion view (mirrors the deploy path), so a consumer of only the view
|
|
* links back to this producer instead of orphaning.
|
|
*/
|
|
function materializeOuts(
|
|
parsed: PipelineAnnotations
|
|
): Array<{ kind: AssetKind; path: string; derivedFrom?: string }> {
|
|
if (!parsed.materialize) return []
|
|
const outs: Array<{ kind: AssetKind; path: string; derivedFrom?: string }> = [
|
|
{ kind: parsed.materialize.targetKind, path: parsed.materialize.targetPath }
|
|
]
|
|
const currentPath = scd2CurrentTargetPath(parsed.materialize)
|
|
if (currentPath) {
|
|
outs.push({
|
|
kind: parsed.materialize.targetKind,
|
|
path: currentPath,
|
|
derivedFrom: parsed.materialize.targetPath
|
|
})
|
|
}
|
|
return outs
|
|
}
|
|
|
|
/** Add an output asset node + unsaved write edge for `runnablePath`, deduped
|
|
* against the assets/edges already accumulated (base survivors included). */
|
|
function pushWriteOut(
|
|
acc: Accumulator,
|
|
runnablePath: string,
|
|
out: { kind: AssetKind; path: string; derivedFrom?: string }
|
|
) {
|
|
const existing = acc.assets.find((a) => a.kind === out.kind && a.path === out.path)
|
|
if (!existing) {
|
|
acc.assets.push({
|
|
kind: out.kind,
|
|
path: out.path,
|
|
...(out.derivedFrom ? { derived_from: out.derivedFrom } : {})
|
|
})
|
|
} else if (out.derivedFrom && existing.derived_from == undefined) {
|
|
existing.derived_from = out.derivedFrom
|
|
}
|
|
const hasWriteEdge = acc.edges.some(
|
|
(e) =>
|
|
e.runnable_kind === 'script' &&
|
|
e.runnable_path === runnablePath &&
|
|
e.asset_kind === out.kind &&
|
|
e.asset_path === out.path &&
|
|
(e.access_type === 'w' || e.access_type === 'rw')
|
|
)
|
|
if (hasWriteEdge) return
|
|
acc.edges.push({
|
|
runnable_path: runnablePath,
|
|
runnable_kind: 'script',
|
|
asset_kind: out.kind,
|
|
asset_path: out.path,
|
|
access_type: 'w',
|
|
unsaved: true
|
|
})
|
|
}
|
|
|
|
/** Add an input asset node + unsaved read edge for `runnablePath`, deduped
|
|
* against the assets/edges already accumulated. */
|
|
function pushReadIn(
|
|
acc: Accumulator,
|
|
runnablePath: string,
|
|
inp: { kind: AssetKind; path: string }
|
|
) {
|
|
if (!acc.assets.some((a) => a.kind === inp.kind && a.path === inp.path)) {
|
|
acc.assets.push({ kind: inp.kind, path: inp.path })
|
|
}
|
|
const hasReadEdge = acc.edges.some(
|
|
(e) =>
|
|
e.runnable_kind === 'script' &&
|
|
e.runnable_path === runnablePath &&
|
|
e.asset_kind === inp.kind &&
|
|
e.asset_path === inp.path &&
|
|
(e.access_type === 'r' || e.access_type === 'rw')
|
|
)
|
|
if (hasReadEdge) return
|
|
acc.edges.push({
|
|
runnable_path: runnablePath,
|
|
runnable_kind: 'script',
|
|
asset_kind: inp.kind,
|
|
asset_path: inp.path,
|
|
access_type: 'r',
|
|
unsaved: true
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Every draft contributes: a runnable, output asset(s), a write edge, live
|
|
* read lineage for the active draft, plus its seeded asset/native triggers
|
|
* (template includes `// on schedule …` by default). Iterates the whole
|
|
* `drafts` map so multiple concurrent drafts all render at once.
|
|
*/
|
|
function seedDraftOverlays(acc: Accumulator, input: ResolveGraphInput) {
|
|
const { base, drafts, liveBodyAssets, inferredReadsByPath, inferredWritesByPath } = input
|
|
const { runnables, assets, extraTriggers } = acc
|
|
|
|
for (const [path, d] of drafts) {
|
|
// The open draft's annotations come from the live buffer, not the draft
|
|
// snapshot — the snapshot only syncs on pane teardown, so parsing it here
|
|
// would pin annotation-derived outputs (`// materialize` target, badges)
|
|
// to their pre-edit values until the user clicks away.
|
|
const parsed =
|
|
path === input.liveAnnotations.scriptPath
|
|
? input.liveAnnotations.annotations
|
|
: parsePipelineAnnotations(d.script.content)
|
|
// For the open script, fold in the WASM-inferred column lineage (DuckDB
|
|
// SQL AST) under the same annotation-wins precedence the backend applies
|
|
// on deploy, so the live preview matches what deploys. Only the open
|
|
// script carries live inference (`liveBodyAssets`); other drafts stay
|
|
// annotation-only until they deploy (the backend infers then).
|
|
const inferredCL =
|
|
path === liveBodyAssets.scriptPath ? (liveBodyAssets.columnLineage ?? []) : []
|
|
const mergedCL = mergeColumnLineage(inferredCL, parsed.columnLineage)
|
|
// The `// materialize` target this draft's column lineage describes, so
|
|
// the column graph anchors to it rather than guessing a write-edge.
|
|
const materializeTarget = parsed.materialize
|
|
? { kind: parsed.materialize.targetKind, path: parsed.materialize.targetPath }
|
|
: undefined
|
|
// A draft can coexist with a base entry — during save the refetch
|
|
// lands before drafts cleanup, and a user re-editing a deployed
|
|
// script also produces both. In that case we mutate the existing
|
|
// base runnable to carry `unsaved: true` instead of pushing a
|
|
// duplicate (which would crash svelte-flow's keyed each), so the
|
|
// canvas + trigger-node labels reflect that there's pending body
|
|
// editing for this path.
|
|
// `// macros` library draft: extract the definitions for the live node
|
|
// badge (regex-light; the strict parse happens at deploy).
|
|
const draftMacros = parsed.macros ? extractDraftMacros(d.script.content) : []
|
|
const baseIdx = runnables.findIndex((r) => r.usage_kind === 'script' && r.path === path)
|
|
if (baseIdx === -1) {
|
|
runnables.push({
|
|
path,
|
|
usage_kind: 'script',
|
|
in_pipeline: true,
|
|
partition_kind: parsed.partition?.kind,
|
|
freshness: parsed.freshness?.duration,
|
|
tag: parsed.tag,
|
|
retry: parsed.retry,
|
|
data_tests: parsed.dataTests.length > 0 ? parsed.dataTests : undefined,
|
|
column_lineage: mergedCL.length > 0 ? mergedCL : undefined,
|
|
materialize_target: materializeTarget,
|
|
macros: draftMacros.length > 0 ? draftMacros : undefined,
|
|
unsaved: true
|
|
})
|
|
} else {
|
|
// Refresh annotation-derived badges from the live parse too, so
|
|
// adding/removing `// data_test` / `// column` lines on an
|
|
// already-deployed script updates the badge immediately (not only
|
|
// after redeploy/refetch).
|
|
runnables[baseIdx] = {
|
|
...runnables[baseIdx],
|
|
data_tests: parsed.dataTests.length > 0 ? parsed.dataTests : undefined,
|
|
column_lineage: mergedCL.length > 0 ? mergedCL : undefined,
|
|
materialize_target: materializeTarget,
|
|
macros: draftMacros.length > 0 ? draftMacros : undefined,
|
|
unsaved: true
|
|
}
|
|
}
|
|
// Output asset(s): two-tier resolution.
|
|
// 1. Active draft (the body the user is editing right now):
|
|
// live body inference is authoritative — renaming a
|
|
// CREATE TABLE target or writeS3File path retires the
|
|
// old output node and surfaces the new one as the user
|
|
// types.
|
|
// 2. Inactive draft: its captured `outputAssets` (inferred at
|
|
// creation/last edit, or the seeded output for a fresh draft
|
|
// whose body doesn't yet write anything inferable).
|
|
const liveForThisDraft = liveBodyAssets.scriptPath === path
|
|
const writeOuts: Array<{ kind: AssetKind; path: string; derivedFrom?: string }> = []
|
|
if (liveForThisDraft) {
|
|
writeOuts.push(...extractWrites(liveBodyAssets.assets))
|
|
} else if (d.outputAssets) {
|
|
writeOuts.push(...d.outputAssets)
|
|
}
|
|
// `// materialize <asset>` declares a write output via annotation, not
|
|
// the SQL body, so the body-inference tiers above miss it (pushWriteOut
|
|
// dedups against existing assets/edges).
|
|
writeOuts.push(...materializeOuts(parsed))
|
|
for (const out of writeOuts) {
|
|
pushWriteOut(acc, path, out)
|
|
}
|
|
// Read lineage: live inference for the open draft, the captured
|
|
// `inputAssets` snapshot for inactive ones (with the session cache as a
|
|
// legacy fallback). Without the inactive tier, merely selecting another
|
|
// node would drop this draft's input edges from the canvas.
|
|
const draftReads = liveForThisDraft
|
|
? extractReads(liveBodyAssets.assets)
|
|
: (d.inputAssets ?? inferredReadsByPath.get(path) ?? [])
|
|
for (const inp of draftReads) {
|
|
pushReadIn(acc, path, inp)
|
|
}
|
|
// Seed trigger edges from the draft's template so the graph stays
|
|
// stable when the user clicks off this draft. Live annotations
|
|
// (below) take over for the currently-open draft so keystroke
|
|
// edits still update in real time.
|
|
for (const a of parsed.triggerAssets) {
|
|
extraTriggers.push({
|
|
trigger_kind: 'asset',
|
|
asset_kind: a.kind,
|
|
asset_path: a.path,
|
|
runnable_kind: 'script',
|
|
runnable_path: path,
|
|
unsaved: true
|
|
})
|
|
// Also synthesize the asset node so the trigger edge has a
|
|
// target even if the upstream asset isn't in base (e.g. the
|
|
// producer script is in another folder we haven't fetched
|
|
// or also a draft).
|
|
const hasTriggerAsset = assets.some((x) => x.kind === a.kind && x.path === a.path)
|
|
if (!hasTriggerAsset) assets.push({ kind: a.kind, path: a.path })
|
|
}
|
|
// Auto-derived cascade edges (backend parity): a ducklake/s3 read wires
|
|
// the edge from the body alone. Reads reuse the tier above; writes come
|
|
// from the active draft's live inference, else the captured snapshot /
|
|
// session cache. The open buffer's derived edges are re-computed
|
|
// authoritatively in applyLiveBufferOverlay (which strips this path's
|
|
// seeded triggers first), same as the explicit `// on` triggers above.
|
|
const draftWrites = liveForThisDraft
|
|
? extractWrites(liveBodyAssets.assets)
|
|
: (d.outputAssets ?? inferredWritesByPath.get(path) ?? [])
|
|
for (const a of deriveAutoAssetTriggers(draftReads, draftWrites, parsed)) {
|
|
extraTriggers.push({
|
|
trigger_kind: 'asset',
|
|
asset_kind: a.kind,
|
|
asset_path: a.path,
|
|
runnable_kind: 'script',
|
|
runnable_path: path,
|
|
unsaved: true
|
|
})
|
|
if (!assets.some((x) => x.kind === a.kind && x.path === a.path)) {
|
|
assets.push({ kind: a.kind, path: a.path })
|
|
}
|
|
}
|
|
// Native trigger annotations on a draft are "missing" until a
|
|
// matching trigger row exists. A brand-new draft never has one (the
|
|
// script isn't deployed yet), but a draft promoted from unsaved
|
|
// edits to a *deployed* script keeps its persisted trigger rows —
|
|
// they bind by path and are not dropped above. Dedup against those
|
|
// so the canvas doesn't show the real schedule node next to a red
|
|
// "missing" placeholder for the same kind.
|
|
pushMissingNativeTriggers(
|
|
extraTriggers,
|
|
parsed.nativeTriggers.map((n) => n.kind),
|
|
persistedNativeKinds(base, path),
|
|
path,
|
|
{ unsaved: true }
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Live-parsed overlay for the currently-open script — takes precedence over
|
|
* the seeded-template triggers for the same path by swapping them out. Scoped
|
|
* to one path (only one pane is open at a time).
|
|
*/
|
|
function applyLiveBufferOverlay(acc: Accumulator, input: ResolveGraphInput, ctx: ResolveContext) {
|
|
const { base, liveAnnotations } = input
|
|
const { assets, extraTriggers } = acc
|
|
const livePath = liveAnnotations.scriptPath
|
|
if (!livePath) return
|
|
|
|
// When the open buffer is a draft, its base asset triggers were dropped
|
|
// above (draft-owned), so don't dedup live asset triggers against them —
|
|
// every live `// on <asset>` is emitted as unsaved. For a non-draft open
|
|
// script (viewing a saved one), keep deduping so live re-parse doesn't
|
|
// double the persisted triggers.
|
|
const assetKeys = ctx.draftedPaths.has(livePath)
|
|
? new Set<string>()
|
|
: persistedAssetKeys(base, livePath)
|
|
// Strip seeded triggers we computed above for the active draft;
|
|
// live annotations are authoritative for the open buffer.
|
|
for (let i = extraTriggers.length - 1; i >= 0; i--) {
|
|
if (extraTriggers[i].runnable_path === livePath) extraTriggers.splice(i, 1)
|
|
}
|
|
for (const a of liveAnnotations.annotations.triggerAssets) {
|
|
const key = `${a.kind}:${a.path}`
|
|
if (assetKeys.has(key)) continue
|
|
extraTriggers.push({
|
|
trigger_kind: 'asset',
|
|
asset_kind: a.kind,
|
|
asset_path: a.path,
|
|
runnable_kind: 'script',
|
|
runnable_path: livePath,
|
|
unsaved: true
|
|
})
|
|
// Synthesize the asset node so the new trigger edge has a
|
|
// target — without this, typing `// on s3:///...` adds an
|
|
// edge to a node that doesn't exist and the canvas silently
|
|
// drops it. Mirrors the draft branch above.
|
|
if (!assets.some((x) => x.kind === a.kind && x.path === a.path)) {
|
|
assets.push({ kind: a.kind, path: a.path })
|
|
}
|
|
}
|
|
// Auto-derived cascade edges (backend parity): a ducklake/s3 read wires
|
|
// the edge from the FROM clause alone, keystroke-live. The open buffer's
|
|
// live body inference is authoritative for its reads/writes. `// mute` /
|
|
// `// mute all` and explicit `// on` (above) suppress a derived edge; a
|
|
// derived edge already persisted for a non-draft open script is deduped
|
|
// via `assetKeys`.
|
|
if (input.liveBodyAssets.scriptPath === livePath) {
|
|
const reads = extractReads(input.liveBodyAssets.assets)
|
|
const writes = extractWrites(input.liveBodyAssets.assets)
|
|
for (const a of deriveAutoAssetTriggers(reads, writes, liveAnnotations.annotations)) {
|
|
if (assetKeys.has(`${a.kind}:${a.path}`)) continue
|
|
extraTriggers.push({
|
|
trigger_kind: 'asset',
|
|
asset_kind: a.kind,
|
|
asset_path: a.path,
|
|
runnable_kind: 'script',
|
|
runnable_path: livePath,
|
|
unsaved: true
|
|
})
|
|
if (!assets.some((x) => x.kind === a.kind && x.path === a.path)) {
|
|
assets.push({ kind: a.kind, path: a.path })
|
|
}
|
|
}
|
|
}
|
|
// Lineage overlay for an open SAVED script. seedDraftOverlays owns drafts,
|
|
// but a deployed script's unsaved edits are only promoted to a draft on
|
|
// pane teardown — until then the buffer's lineage lives here. staleForOpen
|
|
// already dropped the base edges the buffer no longer references; this adds
|
|
// the ones it now does. Without it, retargeting `// materialize` (or a body
|
|
// write/read) shows no output edge until the user clicks away.
|
|
if (!ctx.draftedPaths.has(livePath)) {
|
|
for (const out of materializeOuts(liveAnnotations.annotations)) {
|
|
pushWriteOut(acc, livePath, out)
|
|
}
|
|
if (input.liveBodyAssets.scriptPath === livePath) {
|
|
for (const out of extractWrites(input.liveBodyAssets.assets)) {
|
|
pushWriteOut(acc, livePath, out)
|
|
}
|
|
for (const inp of extractReads(input.liveBodyAssets.assets)) {
|
|
pushReadIn(acc, livePath, inp)
|
|
}
|
|
}
|
|
}
|
|
// Native trigger annotations: kinds for which a matching trigger
|
|
// row was found in the backend response. If the live buffer
|
|
// declares `// on kafka` and at least one kafka_trigger row points
|
|
// at this script, the source node is already on the canvas — no
|
|
// overlay needed. Otherwise emit a "missing" placeholder so the
|
|
// user can either create the trigger row or remove the annotation.
|
|
pushMissingNativeTriggers(
|
|
extraTriggers,
|
|
liveAnnotations.annotations.nativeTriggers.map((n) => n.kind),
|
|
persistedNativeKinds(base, livePath),
|
|
livePath,
|
|
{ unsaved: true }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Cross-check for already-deployed scripts (not the open buffer): if a
|
|
* script's persisted body declares `// on kafka` but no matching kafka_trigger
|
|
* row points at it, surface a red placeholder. The annotated-kinds map is
|
|
* filled by the page-level prefetch sweep (one read per script in the folder);
|
|
* drafts and the active editor are handled by the earlier passes. Scripts not
|
|
* yet swept contribute nothing — they surface on the next refetch.
|
|
*/
|
|
function crossCheckSweptScripts(acc: Accumulator, input: ResolveGraphInput) {
|
|
const { base, drafts, liveAnnotations, annotatedNativeKindsByPath } = input
|
|
const livePath = liveAnnotations.scriptPath
|
|
for (const [scriptPath, kinds] of annotatedNativeKindsByPath) {
|
|
if (drafts.has(scriptPath)) continue
|
|
if (scriptPath === livePath) continue
|
|
pushMissingNativeTriggers(
|
|
acc.extraTriggers,
|
|
kinds,
|
|
persistedNativeKinds(base, scriptPath),
|
|
scriptPath,
|
|
{ unsaved: false }
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Live body-asset lineage for any persisted script inferred at least once this
|
|
* session (maps filled by `handleAssetsChange` + the load prefetch). Drafts
|
|
* are handled by `seedDraftOverlays`. For scripts whose deploy didn't persist
|
|
* their body assets (e.g. older WASM at save time, or object-form
|
|
* writeS3File), this keeps the lineage edge on the canvas across selection
|
|
* changes — not just while selected.
|
|
*/
|
|
function overlayInferredLineage(acc: Accumulator, input: ResolveGraphInput) {
|
|
const { drafts, inferredWritesByPath, inferredReadsByPath } = input
|
|
const { assets, edges } = acc
|
|
|
|
const overlayLineage = (
|
|
byPath: Map<string, Array<{ kind: AssetKind; path: string }>>,
|
|
access: 'w' | 'r'
|
|
) => {
|
|
for (const [scriptPath, refs] of byPath) {
|
|
if (drafts.has(scriptPath)) continue
|
|
for (const a of refs) {
|
|
// Dedup against the ACCUMULATED edges, not just base: for the open
|
|
// script these same refs may already be overlaid by
|
|
// applyLiveBufferOverlay (which feeds the maps on the route page),
|
|
// and a duplicate would collide on the canvas's endpoint-derived
|
|
// edge ids.
|
|
const hasEdge = edges.some(
|
|
(e) =>
|
|
e.runnable_path === scriptPath &&
|
|
e.runnable_kind === 'script' &&
|
|
e.asset_kind === a.kind &&
|
|
e.asset_path === a.path &&
|
|
(e.access_type === access || e.access_type === 'rw')
|
|
)
|
|
if (hasEdge) continue
|
|
if (!assets.some((x) => x.kind === a.kind && x.path === a.path)) {
|
|
assets.push({ kind: a.kind, path: a.path })
|
|
}
|
|
edges.push({
|
|
runnable_path: scriptPath,
|
|
runnable_kind: 'script',
|
|
asset_kind: a.kind,
|
|
asset_path: a.path,
|
|
access_type: access,
|
|
unsaved: true
|
|
})
|
|
}
|
|
}
|
|
}
|
|
overlayLineage(inferredWritesByPath, 'w')
|
|
overlayLineage(inferredReadsByPath, 'r')
|
|
}
|
|
|
|
/** dbt project ↔ relation association, for a graph that does NOT draw it.
|
|
*
|
|
* A dbt script owns every relation of its project, so drawing one edge per
|
|
* model buries the lineage that matters — `ref()` between models, and native
|
|
* consumers — under a fan-out that grows with the project. The canvas suppresses
|
|
* those edges and conveys the association through the model's badge instead:
|
|
* `ownerByAsset` powers badge → project, `writesByOwner` powers project →
|
|
* the transforms it materializes (its sources are inputs, not transforms).
|
|
*
|
|
* Only the DRAWING is dropped. The producer rows these are derived from still
|
|
* answer "who produced this".
|
|
*/
|
|
export function dbtAssociations(
|
|
runnables: Array<{ usage_kind: string; path: string; dbt?: unknown }>,
|
|
edges: Array<{
|
|
runnable_kind: string
|
|
runnable_path: string
|
|
asset_kind: string
|
|
asset_path: string
|
|
access_type?: string | null
|
|
}>
|
|
): { ownerByAsset: Map<string, string>; writesByOwner: Map<string, Set<string>> } {
|
|
const dbtRunnableIds = new Set(
|
|
runnables.filter((r) => r.dbt).map((r) => `${r.usage_kind}:${r.path}`)
|
|
)
|
|
const ownerByAsset = new Map<string, string>()
|
|
const writesByOwner = new Map<string, Set<string>>()
|
|
// Candidates per relation, kept apart so the choice does not depend on the
|
|
// order the edges arrive in. Two things decide it: a project that WRITES a
|
|
// relation outranks one that merely declares it as a SOURCE, and among
|
|
// several writers — which the backend permits, two selections of one project
|
|
// materializing the same model — the smallest id wins. Anything less makes
|
|
// the badge lead somewhere else between two loads of the same graph.
|
|
const writersByAsset = new Map<string, string[]>()
|
|
const readersByAsset = new Map<string, string[]>()
|
|
for (const e of edges) {
|
|
const runnableId = `${e.runnable_kind}:${e.runnable_path}`
|
|
if (!dbtRunnableIds.has(runnableId)) continue
|
|
const assetId = `asset:${e.asset_kind}:${e.asset_path}`
|
|
const writes = e.access_type === 'w' || e.access_type === 'rw'
|
|
const into = writes ? writersByAsset : readersByAsset
|
|
let candidates = into.get(assetId)
|
|
if (!candidates) into.set(assetId, (candidates = []))
|
|
candidates.push(runnableId)
|
|
if (writes) {
|
|
let set = writesByOwner.get(runnableId)
|
|
if (!set) writesByOwner.set(runnableId, (set = new Set()))
|
|
set.add(assetId)
|
|
}
|
|
}
|
|
for (const [assetId, candidates] of [...readersByAsset, ...writersByAsset]) {
|
|
ownerByAsset.set(assetId, [...candidates].sort()[0])
|
|
}
|
|
return { ownerByAsset, writesByOwner }
|
|
}
|