Files
windmill/frontend/src/lib/components/assets/AssetGraph/resolveGraph.test.ts
T
Ruben FiszelandClaude Opus 5 c6e0302d7c feat: let // materialize declare a dbt:// warehouse-relation write (#10978)
* feat: let `// materialize` declare a `dbt://` warehouse-relation write

`// materialize manual dbt://<warehouse>/<schema>/<name>` lets an ingestion
script in any language declare that it writes a warehouse relation, so it and
the dbt model reading that relation land on one asset node instead of two
disconnected pictures. `manual` is the only mode a warehouse target has —
nothing generates warehouse DDL — and the non-`manual` spelling is refused
rather than silently degraded. The `<warehouse>` segment is resolved against
the workspace's configured warehouses, like a descriptor's `profile.warehouse`.

The run records the same `materialized_partition` row a DuckLake target does,
from the generic job path rather than an executor: the DuckLake write engine is
DuckDB's, this declaration is anyone's.

With a non-dbt producer now possible, the blanket deploy-time refusal of
`# on dbt://<relation>` narrows to the shape that still cannot fire — every
writer of the relation being a dbt script, since a dbt run does not dispatch.
"Nothing produces it yet" stays accepted, as for every other asset kind, so
deploy order does not matter. A dbt script may not subscribe at all: its graph
ingest clears its own `dbt://` trigger rows. The one ordering the deploy cannot
catch — a subscription accepted before any producer, then claimed by a dbt
project — is named in that project's deploy log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw1WrKeRRzyYHjfkuB83ek

* fix: address review — preview stamping, stale producer set, public doc

Three findings from the local review round:

- Record the warehouse write only for a DEPLOYED script job. The annotation is
  a deploy-time contract (`manual`, three segments, a configured warehouse)
  checked where write access to the path is also required; honouring it in a
  preview, hub or inline-flow body let `jobs:run` alone restamp any relation's
  last writer from a script that never touched it.
- Exclude the deploying script's own rows from the producer set. Read
  committed, they describe the version being replaced, so a script dropping its
  `// materialize` while adding a subscription counted itself as the producer
  that would wake it and committed a dormant edge. It could not be that
  producer anyway — the dispatcher skips self-loops.
- `AssetKind::Dbt`'s doc no longer claims dbt is the exclusive producer of a
  warehouse relation, on both the types and the parser enum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: review round 1 — dbt-script materialize, set-form rule, doc

- Refuse `// materialize` on a dbt script, the producer half of the rule the
  trigger loop already applies to `// on`: the graph ingest republishes that
  path's asset rows wholesale, so a declared write is wiped by the deploy that
  accepted it while its runs keep stamping the relation.
- `dormant_dbt_subscriptions` now spells the same predicate its singular sibling
  does: the producer set has to be non-empty (nothing produces it yet is deploy
  order, not a dormant edge) and excludes the subscriber's own path (a script
  never wakes itself). Both divergences are pinned by tests.
- The docs no longer claim the dbt deploy log covers a native producer that drops
  its `// materialize`; it does not, and nothing else reports that case.
- An integration test over the deploy contract, since only a real deploy proves
  the handler feeds `sole_dbt_producer` the canonical key `asset.path` holds —
  the spelling that has to agree across the materialize target, the `// on` ref
  and the refusal that joins them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: qualify the any-language claim, and pin the dbt-script refusal

`AssetKind::Dbt`'s contract (both enums), the two runtime guides and the deploy
comment said a script of any language may declare a `dbt://` write, which the
dbt-script refusal added last round contradicts. They now say "any language but
dbt's own", with the reason: a project's writes are read from its manifest.

The deploy-contract integration test covers that refusal for both annotations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: teach the pipeline AI guidance the warehouse-relation target

The pipeline prompt (both sources, plus the regenerated bundle) told the model
`// materialize` is DuckDB-only and rejected on any other target, which now
steers users away from the very thing this PR adds. It distinguishes the managed
DuckLake write, still DuckDB-only, from the warehouse-relation declaration any
language but dbt's own may make.

`dbt_manifest.rs`'s module doc carried the same "the only thing that creates one"
overclaim the other four sites lost last commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: draw an explicit dbt:// subscription on the canvas

The editor suppressed every `// on dbt://…` overlay, which was right while the
deploy refused all of them. It now refuses only a relation dbt alone builds, so
the suppression hid the author's own annotation for exactly the case this PR
adds — a subscription woken by a native `// materialize manual dbt://…`
producer. The deploy stays the gate.

Also the two stale claims round 4 named: the live pipeline prompt dropped the
dbt-script exception the base prompt carries, and the doc's e2e requirements
still said every `dbt://` subscription is refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse `// data_test` beside a `dbt://` materialize target

`// data_test` checks are verifier probes the DuckDB executor splices around a
managed write. A warehouse relation is written by the script itself, in any
language, so nothing would run them — and unlike the DuckLake `manual` case,
which at least fails loudly in that executor, a declarer in another language
deployed green with its data-quality assertions silently skipped.

Covered in the deploy-contract test and documented beside the annotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: exclude a renamed producer from the sole-dbt producer set

The producer set already excluded the deploying script's own path, because its
committed rows describe the version being replaced. Under a rename the write
sits at the OLD path — still committed, and removed by the same uncommitted
transaction — so a producer renamed while it drops its `// materialize` and adds
`// on dbt://…` still counted as the producer that would wake it, and committed
a dormant edge.

The deploy-contract test covers it: without the exclusion the rename deploys
201 instead of being refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: take the rename test's parent hash from the create response

`format!("{:x}", …)` over the stored i64 drops leading zeros, while
`ScriptHash`'s deserializer hex-decodes and demands 8 bytes — so a hash below
2^60 would 422 the request instead of reaching the refusal it asserts on, on
roughly one in sixteen spellings of that script body. The create response
already carries the zero-padded form, as the rest of the suite uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: state the concurrent-ingest interleaving honestly

`sole_dbt_producer`'s doc claimed the concurrent-deploy race only ever resolves
toward refusing. It does when the uncommitted producer is native; when it is the
dbt ingest, the check sees an empty producer set and accepts, and if that ingest
then commits and runs its warning query before the subscriber's trigger row
lands, neither side reports the dormant edge.

Not serialized: the two would have to share a per-relation lock, and the ingest
takes `script … FOR UPDATE` before its own advisory lock, so a deploy holding
relation locks first inverts that order into a cross-subsystem deadlock — a worse
failure than the cosmetic edge. Recorded beside the other orphaning the deploy
cannot catch, with the bound both share: the next deploy of that project warns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse a `dbt://` subscription that is not a whole relation

`# on dbt://main/analytics` deployed and persisted a trigger row. Every producer
spells `<warehouse>/<schema>/<name>` — the manifest ingest derives it from
`relation_name`, a `// materialize` target is checked against it — so a partial
one is an edge nothing can ever wake, which is what the dbt-only refusal exists
to prevent.

The shape now has one definition (`is_full_relation_path`) that both halves of
the deploy ask, rather than a segment count spelled twice: a subscription and a
write that disagreed would refuse and accept the same string.

Also rewrites the canvas test's comment as a current constraint per AGENTS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hold both halves of the deploy to one `dbt://` relation validator

A subscription checked the relation's shape but not its warehouse, so
`# on dbt://<unconfigured>/<schema>/<name>` deployed and persisted a trigger row
for something no producer can ever write: the write side refuses that exact
string, and a dbt project's `profile.warehouse` resolves against the same config,
so no later deploy fixes it and the dormant-edge warning cannot report it either.

The shape rule and the warehouse rule now live in one `validate_dbt_relation`
that both halves call, rather than being spelled per site — the previous two
rounds each closed one half of one rule, which is the drift that invites.

Also moves the parser test out from between a comment and the test it documents,
and names both refusals in the doc's list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop the subscription-only clause from the shared refusal message

"so nothing can produce it" reads backwards on the `// materialize` side, which
is the producer. The remaining sentence says what is wrong on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: bound a `dbt://` relation by the asset-path column in the shared validator

`asset.path` is VARCHAR(255) and the manifest ingest drops a relation that
outgrows it rather than failing the whole graph, so past the column no producer
row can exist on either side. `script_trigger.trigger_ref` is unbounded text, so
an overlong subscription deployed and stayed dormant for good; an overlong write
reached Postgres and failed the deploy on a `value too long` instead of a message.

Both now refuse in the validator the two halves share, against the ingest's own
constant. The integration case computes the ref from that constant so it cannot
drift back under the bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: report a warehouse-lookup failure as the failure it is, and correct the boundary

`dbt_warehouse_exists` fails three ways — no such warehouse, the query itself,
and a setting with no `resource_path` — and all three became a 400 blaming the
user's warehouse name. A pool timeout mid-deploy told a retrying sync that a
transient server error was a permanent client one. Only `NotFound` is the
annotation's fault now.

The known-boundary paragraph claimed a flow-runner run still cascades. It does
not: it is routed by `flow_step_id`, which `is_eligible_kind` rejects, as
`asset_trigger_dispatch.rs` pins. Recording and cascading are decided separately,
so the paragraph now names all three routes rather than merging two of them — and
the row it omitted, an ordinary flow step, which records and never cascades.

E2E item 7 said "deployable" where the rule is "wakeable": with only the dbt
project reading the relation the producer set is empty, which deploys fine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct two rationales the last commit got wrong

`Error::SqlErr` already maps to 400 in this codebase, so the query case's status
was never the thing at stake. What the `NotFound` match earns is that a query
failure and a malformed setting stop being described as an unconfigured warehouse
name, and that the malformed-setting `InternalErr` reaches its own 500 instead of
being flattened.

And a flow step is two shapes, not one: a step running a deployed script is a
`Script` job that records and never cascades, while a step with an inline body is
`FlowScript`, which the recording guard excludes along with previews.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: warn about dormant subscriptions from the run that publishes ownership too

A run whose static descriptor finds its profile moved re-ingests the version's
graph and republishes path ownership, exactly as a deploy does — so it can be
what leaves a subscription accepted while the relation had no producer with dbt
as its only one. That path discarded `persist_ingest`'s result and emitted no
warning, which also made the doc's enumeration of unreported orphanings wrong.

Both ownership-publishing points warn now. An agent worker still cannot: it
reaches these tables only through the API and its ingest publishes without
reading back, which the doc now says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: an agent run publishes no ownership, and the warning has two callers

The agent-worker sentence called it an exception that publishes ownership without
warning. It publishes none: `Connection::Http` forces per-run models, and
`publishes_ownership()` is the negation of that, so an agent stores a job-pinned
snapshot and leaves workspace ownership with the deployed graph — it cannot orphan
a subscription at all.

`warn_dormant_subscribers`' own doc still named the deploy log as the only place
the warning shows, one commit after it gained its second caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: stop the managed-write rule from contradicting the dbt:// target

The sentence after the warehouse-relation paragraph says `// materialize` means
the runtime writes the table for you and the body is a bare SELECT. That is the
managed DuckLake rule, written before a `dbt://` target existed, and unqualified
it tells the model the opposite of what the paragraph above it just said — a
model following the more prominent one emits a SELECT for a warehouse relation,
which deploys and then writes nothing.

Both prompt sources now scope it, and both name the `// data_test` refusal beside
a `dbt://` target, which the badge list advertised without the caveat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:38:07 +02:00

1170 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
resolveGraph,
computeMutedReadKeys,
dbtAssociations,
type ResolveGraphInput
} from './resolveGraph'
import type { PipelineAnnotations } from './parsePipelineAnnotations'
import type { AssetGraphResponse } from './types'
import type { AssetWithAltAccessType } from '$lib/components/assets/lib'
const ann = (over: Partial<PipelineAnnotations> = {}): PipelineAnnotations => ({
inPipeline: false,
triggerAssets: [],
nativeTriggers: [],
dataTests: [],
columnLineage: [],
macros: false,
useLibs: [],
muteAssets: [],
muteAll: false,
...over
})
const baseGraph = (over: Partial<AssetGraphResponse> = {}): AssetGraphResponse => ({
assets: [],
runnables: [],
edges: [],
triggers: [],
...over
})
const input = (over: Partial<ResolveGraphInput> = {}): ResolveGraphInput => ({
base: baseGraph(),
drafts: new Map(),
liveBodyAssets: { scriptPath: undefined, assets: [] },
liveAnnotations: { scriptPath: undefined, annotations: ann() },
inferredWritesByPath: new Map(),
inferredReadsByPath: new Map(),
annotatedNativeKindsByPath: new Map(),
...over
})
const s3 = (path: string, access_type: 'r' | 'w' | 'rw'): AssetWithAltAccessType =>
({ kind: 's3object', path, access_type }) as AssetWithAltAccessType
const duck = (path: string, access_type: 'r' | 'w' | 'rw'): AssetWithAltAccessType =>
({ kind: 'ducklake', path, access_type }) as AssetWithAltAccessType
/** kind:path of the unsaved asset-trigger overlays for a runnable. */
const assetTrigKeys = (r: AssetGraphResponse, path: string): string[] =>
r.triggers
.filter((t) => t.trigger_kind === 'asset' && t.runnable_path === path && (t as any).unsaved)
.map((t) => `${(t as any).asset_kind}:${(t as any).asset_path}`)
describe('resolveGraph', () => {
it('passes an empty graph through unchanged', () => {
const r = resolveGraph(input())
expect(r).toEqual({ assets: [], runnables: [], edges: [], triggers: [] })
})
it('preserves persisted base nodes/edges/triggers', () => {
const base = baseGraph({
assets: [{ kind: 's3object', path: '/a.json' }],
runnables: [{ path: 'f/x/prod', usage_kind: 'script' }],
edges: [
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: '/a.json',
access_type: 'w'
}
],
triggers: [
{
trigger_kind: 'asset',
asset_kind: 's3object',
asset_path: '/a.json',
runnable_kind: 'script',
runnable_path: 'f/x/cons'
}
]
})
expect(resolveGraph(input({ base }))).toEqual(base)
})
it('draft: adds an unsaved runnable + write edge from outputAssets', () => {
const drafts = new Map([
[
'f/x/d',
{
script: { content: '' },
outputAssets: [{ kind: 's3object' as const, path: '/out.json' }]
}
]
])
const r = resolveGraph(input({ drafts }))
expect(r.runnables).toContainEqual({
path: 'f/x/d',
usage_kind: 'script',
in_pipeline: true,
partition_kind: undefined,
freshness: undefined,
unsaved: true
})
expect(r.assets).toContainEqual({ kind: 's3object', path: '/out.json' })
expect(r.edges).toContainEqual({
runnable_path: 'f/x/d',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: '/out.json',
access_type: 'w',
unsaved: true
})
})
it('scd2 materialize draft: writes both the base dim and its _current companion view', () => {
const drafts = new Map([
[
'f/x/dim',
{
script: {
content: '-- materialize ducklake://main/dim_customers key=id history\nselect 1'
}
}
]
])
const r = resolveGraph(input({ drafts }))
// Base dimension: a plain write output node.
expect(r.assets).toContainEqual({ kind: 'ducklake', path: 'main/dim_customers' })
// Companion `_current` view: same producer, marked derived from the base.
expect(r.assets).toContainEqual({
kind: 'ducklake',
path: 'main/dim_customers_current',
derived_from: 'main/dim_customers'
})
for (const path of ['main/dim_customers', 'main/dim_customers_current']) {
expect(r.edges).toContainEqual({
runnable_path: 'f/x/dim',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: path,
access_type: 'w',
unsaved: true
})
}
})
it('non-scd2 materialize draft: writes only the base dim, no _current companion', () => {
const drafts = new Map([
[
'f/x/dim',
{ script: { content: '-- materialize ducklake://main/dim_customers key=id\nselect 1' } }
]
])
const r = resolveGraph(input({ drafts }))
expect(r.assets).toContainEqual({ kind: 'ducklake', path: 'main/dim_customers' })
expect(r.assets.some((a) => a.path === 'main/dim_customers_current')).toBe(false)
})
it('active draft: live body writes are authoritative over the snapshot', () => {
const drafts = new Map([
[
'f/x/d',
{
script: { content: '' },
outputAssets: [{ kind: 's3object' as const, path: '/snapshot.json' }]
}
]
])
const r = resolveGraph(
input({ drafts, liveBodyAssets: { scriptPath: 'f/x/d', assets: [s3('/live.json', 'w')] } })
)
expect(r.edges.map((e) => e.asset_path)).toContain('/live.json')
expect(r.edges.map((e) => e.asset_path)).not.toContain('/snapshot.json')
})
it('session-inferred writes overlay a persisted script, deduped vs base', () => {
const base = baseGraph({
edges: [
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: '/already.json',
access_type: 'w'
}
]
})
const inferredWritesByPath = new Map([
[
'f/x/prod',
[
{ kind: 's3object' as const, path: '/already.json' }, // deduped (in base)
{ kind: 's3object' as const, path: '/fresh.json' } // added unsaved
]
]
])
const r = resolveGraph(input({ base, inferredWritesByPath }))
const w = r.edges.filter((e) => e.runnable_path === 'f/x/prod' && e.access_type === 'w')
expect(w).toHaveLength(2) // 1 persisted + 1 overlay (no dup of /already.json)
expect(w.find((e) => e.asset_path === '/fresh.json')?.unsaved).toBe(true)
expect(r.assets).toContainEqual({ kind: 's3object', path: '/fresh.json' })
})
it('session-inferred reads overlay with r/rw dedup', () => {
const base = baseGraph({
edges: [
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: '/rw.json',
access_type: 'rw'
}
]
})
const inferredReadsByPath = new Map([
[
'f/x/prod',
[
{ kind: 's3object' as const, path: '/rw.json' }, // deduped (base has rw)
{ kind: 's3object' as const, path: '/in.csv' } // added as 'r'
]
]
])
const r = resolveGraph(input({ base, inferredReadsByPath }))
const reads = r.edges.filter((e) => e.access_type === 'r')
expect(reads).toHaveLength(1)
expect(reads[0]).toMatchObject({ asset_path: '/in.csv', unsaved: true })
})
it('does not overlay inferred lineage onto a path that is a draft', () => {
const drafts = new Map([['f/x/d', { script: { content: '' } }]])
const inferredWritesByPath = new Map([
['f/x/d', [{ kind: 's3object' as const, path: '/should-not.json' }]]
])
const r = resolveGraph(input({ drafts, inferredWritesByPath }))
expect(r.edges.find((e) => e.asset_path === '/should-not.json')).toBeUndefined()
})
it('open buffer: a ducklake/s3 read auto-derives an unsaved cascade trigger', () => {
const liveBodyAssets = {
scriptPath: 'f/x/open',
assets: [duck('main.orders', 'r'), s3('raw/events', 'r'), duck('main.out', 'w')]
}
const liveAnnotations = { scriptPath: 'f/x/open', annotations: ann({ inPipeline: true }) }
const r = resolveGraph(input({ liveBodyAssets, liveAnnotations }))
// The two reads derive edges; the write (main.out) does not (self-edge).
expect(assetTrigKeys(r, 'f/x/open').sort()).toEqual([
'ducklake:main.orders',
's3object:raw/events'
])
})
it('open buffer: a materialize producer reading its own target does not self-cascade', () => {
// The body `SELECT`s from the table it materializes (incremental model).
// The write is annotation-declared, not in the body, so without excluding
// the materialize target the read would auto-derive a self-cascade edge.
const liveBodyAssets = { scriptPath: 'f/x/p', assets: [duck('main.orders', 'r')] }
const liveAnnotations = {
scriptPath: 'f/x/p',
annotations: ann({
inPipeline: true,
materialize: { targetKind: 'ducklake' as const, targetPath: 'main.orders' }
})
}
const r = resolveGraph(input({ liveBodyAssets, liveAnnotations }))
expect(assetTrigKeys(r, 'f/x/p')).toEqual([])
})
it('open buffer: a read that is also written (rw) does not self-trigger', () => {
const liveBodyAssets = { scriptPath: 'f/x/open', assets: [duck('main.self', 'rw')] }
const liveAnnotations = { scriptPath: 'f/x/open', annotations: ann({ inPipeline: true }) }
const r = resolveGraph(input({ liveBodyAssets, liveAnnotations }))
expect(assetTrigKeys(r, 'f/x/open')).toEqual([])
})
it('open buffer: // mute suppresses one derived edge, // mute all suppresses all', () => {
const liveBodyAssets = {
scriptPath: 'f/x/open',
assets: [duck('main.a', 'r'), duck('main.b', 'r')]
}
const muted = resolveGraph(
input({
liveBodyAssets,
liveAnnotations: {
scriptPath: 'f/x/open',
annotations: ann({ inPipeline: true, muteAssets: [{ kind: 'ducklake', path: 'main.a' }] })
}
})
)
expect(assetTrigKeys(muted, 'f/x/open')).toEqual(['ducklake:main.b'])
const all = resolveGraph(
input({
liveBodyAssets,
liveAnnotations: {
scriptPath: 'f/x/open',
annotations: ann({ inPipeline: true, muteAll: true })
}
})
)
expect(assetTrigKeys(all, 'f/x/open')).toEqual([])
})
it('open buffer: an explicit // on is not double-emitted as a derived edge', () => {
const liveBodyAssets = { scriptPath: 'f/x/open', assets: [duck('main.orders', 'r')] }
const liveAnnotations = {
scriptPath: 'f/x/open',
annotations: ann({
inPipeline: true,
triggerAssets: [{ kind: 'ducklake', path: 'main.orders' }]
})
}
const r = resolveGraph(input({ liveBodyAssets, liveAnnotations }))
// Exactly one overlay for the table, not one explicit + one derived.
expect(assetTrigKeys(r, 'f/x/open')).toEqual(['ducklake:main.orders'])
})
// A relation a native `// materialize manual dbt://…` script writes wakes its
// subscribers, so hiding the edge would leave the author's own annotation off
// the canvas. The deploy refuses the ones that cannot fire.
it('draws an explicit dbt:// subscription as an unsaved trigger overlay', () => {
const liveAnnotations = {
scriptPath: 'f/x/open',
annotations: ann({ triggerAssets: [{ kind: 'dbt', path: 'main/analytics/orders' }] })
}
const r = resolveGraph(input({ liveAnnotations }))
expect(assetTrigKeys(r, 'f/x/open')).toEqual(['dbt:main/analytics/orders'])
})
it('open-script live annotations add unsaved triggers, deduped vs persisted', () => {
const base = baseGraph({
triggers: [
{
trigger_kind: 'asset',
asset_kind: 's3object',
asset_path: '/persisted.json',
runnable_kind: 'script',
runnable_path: 'f/x/open'
}
]
})
const liveAnnotations = {
scriptPath: 'f/x/open',
annotations: ann({
triggerAssets: [
{ kind: 's3object', path: '/persisted.json' }, // deduped
{ kind: 's3object', path: '/new-trigger.json' } // added unsaved
],
nativeTriggers: [{ kind: 'schedule' }]
})
}
const r = resolveGraph(input({ base, liveAnnotations }))
const assetTrigs = r.triggers.filter(
(t) => t.trigger_kind === 'asset' && t.runnable_path === 'f/x/open'
)
expect(assetTrigs).toHaveLength(2) // 1 persisted + 1 unsaved overlay
expect(
r.triggers.find(
(t) => t.trigger_kind === 'asset' && (t as any).asset_path === '/new-trigger.json'
)?.unsaved
).toBe(true)
// Schedule joins the native-trigger family — marker-only, missing
// until the user creates the schedule row separately.
expect(
r.triggers.some(
(t) =>
t.trigger_kind === 'schedule' &&
(t as any).missing === true &&
t.runnable_path === 'f/x/open'
)
).toBe(true)
})
it('editing a saved script shows only the draft I/O, not the persisted edges', () => {
// Saved script with persisted read/write lineage + an asset trigger.
const base = baseGraph({
runnables: [{ path: 'f/x/prod', usage_kind: 'script' }],
edges: [
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: '/old-out.json',
access_type: 'w'
},
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: '/old-in.json',
access_type: 'r'
}
],
triggers: [
{
trigger_kind: 'asset',
asset_kind: 's3object',
asset_path: '/old-trig.json',
runnable_kind: 'script',
runnable_path: 'f/x/prod'
}
]
})
// The user is now editing it: a draft at the same path, open, with new
// live-inferred reads/writes and a new live `// on` annotation.
const drafts = new Map([['f/x/prod', { script: { content: '// on s3:///new-trig.json' } }]])
const r = resolveGraph(
input({
base,
drafts,
liveBodyAssets: {
scriptPath: 'f/x/prod',
assets: [s3('/new-out.json', 'w'), s3('/new-in.json', 'r')]
},
liveAnnotations: {
scriptPath: 'f/x/prod',
annotations: ann({ triggerAssets: [{ kind: 's3object', path: '/new-trig.json' }] })
}
})
)
const paths = r.edges.filter((e) => e.runnable_path === 'f/x/prod').map((e) => e.asset_path)
// Only the draft's I/O — the persisted edges are gone.
expect(paths.sort()).toEqual(['/new-in.json', '/new-out.json'])
const trigs = r.triggers.filter((t) => t.runnable_path === 'f/x/prod')
expect(trigs).toHaveLength(1)
expect(trigs[0]).toMatchObject({ trigger_kind: 'asset', asset_path: '/new-trig.json' })
})
it('editing a saved script (open, not a draft) drops the renamed input, keeps output', () => {
// Saved DuckDB-style script: `// on s3://old.csv` + body read of old.csv
// + datatable write. Persisted base has all three.
const base = baseGraph({
runnables: [{ path: 'f/x/prod', usage_kind: 'script' }],
edges: [
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: 'old.csv',
access_type: 'r'
},
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 'datatable',
asset_path: 'main/out',
access_type: 'w'
}
],
triggers: [
{
trigger_kind: 'asset',
asset_kind: 's3object',
asset_path: 'old.csv',
runnable_kind: 'script',
runnable_path: 'f/x/prod'
}
]
})
// Open + edited (NOT a draft): input renamed old.csv → new.csv in both the
// `// on` annotation and the body read; output unchanged.
const r = resolveGraph(
input({
base,
liveAnnotations: {
scriptPath: 'f/x/prod',
annotations: ann({ triggerAssets: [{ kind: 's3object', path: 'new.csv' }] })
},
liveBodyAssets: {
scriptPath: 'f/x/prod',
assets: [
s3('new.csv', 'r'),
{ kind: 'datatable', path: 'main/out', access_type: 'w' } as AssetWithAltAccessType
]
}
})
)
// The old input is gone from both edges and triggers…
expect(r.edges.some((e) => e.asset_path === 'old.csv')).toBe(false)
expect(r.triggers.some((t) => (t as any).asset_path === 'old.csv')).toBe(false)
// …the new input shows (as an unsaved trigger from the live annotation)…
expect(
r.triggers.some(
(t) => t.trigger_kind === 'asset' && (t as any).asset_path === 'new.csv' && t.unsaved
)
).toBe(true)
// …and the unchanged output write edge is preserved.
expect(r.edges.some((e) => e.asset_path === 'main/out' && e.access_type === 'w')).toBe(true)
})
it('editing a saved scd2 producer keeps both the base and _current persisted write edges', () => {
// Deploy persists a write to both `main/dim` and `main/dim_current`.
// Opening the producer for editing must not judge the companion `_current`
// write stale — otherwise a consumer of only the view orphans mid-edit.
const base = baseGraph({
runnables: [{ path: 'f/x/dim', usage_kind: 'script' }],
edges: [
{
runnable_path: 'f/x/dim',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: 'main/dim_customers',
access_type: 'w'
},
{
runnable_path: 'f/x/dim',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: 'main/dim_customers_current',
access_type: 'w'
}
]
})
const r = resolveGraph(
input({
base,
liveAnnotations: {
scriptPath: 'f/x/dim',
annotations: ann({
materialize: {
targetKind: 'ducklake',
targetPath: 'main/dim_customers',
uniqueKey: 'id',
scd2: true
}
})
},
liveBodyAssets: { scriptPath: 'f/x/dim', assets: [] }
})
)
for (const path of ['main/dim_customers', 'main/dim_customers_current']) {
expect(
r.edges.some(
(e) => e.runnable_path === 'f/x/dim' && e.asset_path === path && e.access_type === 'w'
)
).toBe(true)
}
})
it('selecting a saved script unchanged drops nothing (no stale removal)', () => {
const base = baseGraph({
runnables: [{ path: 'f/x/prod', usage_kind: 'script' }],
edges: [
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: 'in.csv',
access_type: 'r'
}
],
triggers: [
{
trigger_kind: 'asset',
asset_kind: 's3object',
asset_path: 'in.csv',
runnable_kind: 'script',
runnable_path: 'f/x/prod'
}
]
})
const r = resolveGraph(
input({
base,
liveAnnotations: {
scriptPath: 'f/x/prod',
annotations: ann({ triggerAssets: [{ kind: 's3object', path: 'in.csv' }] })
},
liveBodyAssets: { scriptPath: 'f/x/prod', assets: [s3('in.csv', 'r')] }
})
)
// Persisted edge + trigger untouched (not marked unsaved, not dropped).
expect(r.edges).toEqual(base.edges)
const assetTrigs = r.triggers.filter((t) => t.trigger_kind === 'asset')
expect(assetTrigs).toHaveLength(1)
expect(assetTrigs[0]).not.toHaveProperty('unsaved', true)
})
it('editing a saved script keeps its native (path-bound) triggers', () => {
// A kafka trigger row points at the script by path — editing the body
// (draft) must not drop it, since the binding survives content edits.
const base = baseGraph({
runnables: [{ path: 'f/x/prod', usage_kind: 'script' }],
triggers: [
{
trigger_kind: 'kafka',
path: 'f/x/kafka_trig',
runnable_kind: 'script',
runnable_path: 'f/x/prod'
} as any
]
})
const drafts = new Map([['f/x/prod', { script: { content: '// on kafka' } }]])
const r = resolveGraph(input({ base, drafts }))
expect(
r.triggers.some(
(t) =>
t.trigger_kind === 'kafka' &&
t.runnable_path === 'f/x/prod' &&
(t as any).path === 'f/x/kafka_trig'
)
).toBe(true)
})
it('live annotations for a draft replace that draft’s seeded triggers', () => {
// Draft body seeds a schedule annotation; the live buffer has
// replaced it with an asset trigger — only the live one should remain.
const drafts = new Map([['f/x/d', { script: { content: '// on schedule' } }]])
const liveAnnotations = {
scriptPath: 'f/x/d',
annotations: ann({ triggerAssets: [{ kind: 's3object', path: '/live-trig.json' }] })
}
const r = resolveGraph(input({ drafts, liveAnnotations }))
const forDraft = r.triggers.filter((t) => t.runnable_path === 'f/x/d')
expect(forDraft).toHaveLength(1)
expect(forDraft[0]).toMatchObject({ trigger_kind: 'asset', unsaved: true })
})
it('draft of a deployed script: no missing placeholder for a native kind with a persisted row', () => {
// Promoted draft (unsaved edits to a deployed script) — the schedule
// row still points at the path, so the seeded `// on schedule`
// annotation must NOT add a red "missing" node next to the real one.
const base = baseGraph({
runnables: [{ path: 'f/x/prod', usage_kind: 'script' }],
triggers: [
{
trigger_kind: 'schedule',
path: 'f/x/sched',
runnable_kind: 'script',
runnable_path: 'f/x/prod'
} as any
]
})
const drafts = new Map([['f/x/prod', { script: { content: '// on schedule' } }]])
const r = resolveGraph(input({ base, drafts }))
const scheduleTriggers = r.triggers.filter(
(t) => t.trigger_kind === 'schedule' && t.runnable_path === 'f/x/prod'
)
expect(scheduleTriggers).toHaveLength(1)
expect((scheduleTriggers[0] as any).missing).toBeUndefined()
})
it('brand-new draft: native annotation without a persisted row still surfaces as missing', () => {
const drafts = new Map([['f/x/new', { script: { content: '// on schedule' } }]])
const r = resolveGraph(input({ drafts }))
expect(r.triggers).toContainEqual({
trigger_kind: 'schedule',
runnable_kind: 'script',
runnable_path: 'f/x/new',
unsaved: true,
missing: true
})
})
it('macro edges: base passes through; live `// use` adds an unsaved via_use edge', () => {
const base = baseGraph({
runnables: [
{
path: 'f/lib/stats',
usage_kind: 'script',
macros: [{ name: 'safe_div', params: 'a, b', is_table: false }]
},
{ path: 'f/x/cons', usage_kind: 'script' }
],
macro_edges: [
{
lib_path: 'f/lib/stats',
consumer_path: 'f/x/cons',
macro_names: ['safe_div'],
via_use: false
}
]
})
const r = resolveGraph(
input({
base,
liveAnnotations: {
scriptPath: 'f/x/other',
annotations: ann({ useLibs: ['f/lib/stats'] })
}
})
)
// Detection edge preserved untouched.
expect(r.macro_edges).toContainEqual({
lib_path: 'f/lib/stats',
consumer_path: 'f/x/cons',
macro_names: ['safe_div'],
via_use: false
})
// Live `// use` synthesizes an unsaved whole-lib edge with the lib's names.
expect(r.macro_edges).toContainEqual({
lib_path: 'f/lib/stats',
consumer_path: 'f/x/other',
macro_names: ['safe_div'],
via_use: true,
unsaved: true
})
})
it('macro edges: removing the `// use` line of an overlaid consumer retires its via_use edge', () => {
const base = baseGraph({
macro_edges: [
{
lib_path: 'f/lib/stats',
consumer_path: 'f/x/cons',
macro_names: ['safe_div'],
via_use: true
}
]
})
const r = resolveGraph(
input({
base,
liveAnnotations: { scriptPath: 'f/x/cons', annotations: ann() }
})
)
expect(r.macro_edges).toEqual([])
})
it('macro edges: draft `// macros` library gets the ƒ badge data from its body', () => {
const drafts = new Map([
[
'f/lib/new',
{
script: {
content: '// macros\nCREATE OR REPLACE MACRO dbl(a) AS a * 2;'
}
}
]
])
const r = resolveGraph(input({ drafts }))
const lib = r.runnables.find((x) => x.path === 'f/lib/new')
expect(lib?.macros).toEqual([{ name: 'dbl', params: 'a', is_table: false }])
})
})
describe('computeMutedReadKeys', () => {
const readEdge = (asset_kind: any, asset_path: string, access: any = 'r') => ({
runnable_path: 'f/x/c',
runnable_kind: 'script' as const,
asset_kind,
asset_path,
access_type: access
})
const assetTrigger = (asset_kind: any, asset_path: string) => ({
trigger_kind: 'asset' as const,
asset_kind,
asset_path,
runnable_kind: 'script' as const,
runnable_path: 'f/x/c'
})
// `f/x/c` (the read consumer) is an in-pipeline script.
const pipelineRunnable = [{ path: 'f/x/c', usage_kind: 'script' as const, in_pipeline: true }]
it('flags a ducklake/s3 read with no cascade trigger as muted', () => {
const muted = computeMutedReadKeys(
[readEdge('ducklake', 'main.orders'), readEdge('s3object', 'raw/events')],
[],
pipelineRunnable
)
expect([...muted].sort()).toEqual([
'ducklake:main.orders->script:f/x/c',
's3object:raw/events->script:f/x/c'
])
})
it('does not flag a read that has a cascade trigger', () => {
const muted = computeMutedReadKeys(
[readEdge('ducklake', 'main.orders')],
[assetTrigger('ducklake', 'main.orders')],
pipelineRunnable
)
expect(muted.size).toBe(0)
})
it('ignores rw self-reads and unsupported kinds', () => {
const muted = computeMutedReadKeys(
[
readEdge('ducklake', 'main.self', 'rw'), // self-read, not muted
readEdge('resource', 'f/db'), // out of scope
readEdge('datatable', 'main.dt') // out of scope
],
[],
pipelineRunnable
)
expect(muted.size).toBe(0)
})
it('does not flag a read whose script also writes the asset', () => {
// Live-overlay shape of a `// materialize` producer reading its own target:
// a separate `'r'` read edge and `'w'` write edge for the same asset. It's
// the script's own output, not a suppressed input, so it is not muted.
const muted = computeMutedReadKeys(
[readEdge('ducklake', 'main.orders', 'r'), readEdge('ducklake', 'main.orders', 'w')],
[],
pipelineRunnable
)
expect(muted.size).toBe(0)
})
it('does not flag a read by a non-pipeline script (auto-derivation never applied)', () => {
// Same read, but the consumer is a plain script (or a flow), not a
// `// pipeline` member — no auto trigger was ever derived to suppress.
const plainScript = [{ path: 'f/x/c', usage_kind: 'script' as const, in_pipeline: false }]
expect(computeMutedReadKeys([readEdge('ducklake', 'main.orders')], [], plainScript).size).toBe(
0
)
const flow = [{ path: 'f/x/c', usage_kind: 'flow' as const, in_pipeline: true }]
expect(computeMutedReadKeys([readEdge('ducklake', 'main.orders')], [], flow).size).toBe(0)
})
})
describe('live buffer overlays (open script)', () => {
// A deployed producer: `f/x/prod` materializes ducklake main/orders.
const deployedProducer = () =>
baseGraph({
assets: [{ kind: 'ducklake', path: 'main/orders' }],
runnables: [{ path: 'f/x/prod', usage_kind: 'script', in_pipeline: true }],
edges: [
{
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: 'main/orders',
access_type: 'w'
}
]
})
it('open draft: live annotations win over the stale draft snapshot for the materialize target', () => {
const drafts = new Map([
['f/x/d', { script: { content: '-- materialize ducklake://main/old_target\nselect 1' } }]
])
const r = resolveGraph(
input({
drafts,
liveBodyAssets: { scriptPath: 'f/x/d', assets: [] },
liveAnnotations: {
scriptPath: 'f/x/d',
annotations: ann({
inPipeline: true,
materialize: { targetKind: 'ducklake', targetPath: 'main/new_target' }
})
}
})
)
expect(r.assets).toContainEqual({ kind: 'ducklake', path: 'main/new_target' })
expect(r.assets).not.toContainEqual({ kind: 'ducklake', path: 'main/old_target' })
expect(r.edges).toContainEqual({
runnable_path: 'f/x/d',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: 'main/new_target',
access_type: 'w',
unsaved: true
})
})
it('open saved script: retargeting `// materialize` swaps the write edge live', () => {
const r = resolveGraph(
input({
base: deployedProducer(),
liveBodyAssets: { scriptPath: 'f/x/prod', assets: [] },
liveAnnotations: {
scriptPath: 'f/x/prod',
annotations: ann({
inPipeline: true,
materialize: { targetKind: 'ducklake', targetPath: 'main/orders_gold' }
})
}
})
)
// New target surfaces as an unsaved write edge…
expect(r.assets).toContainEqual({ kind: 'ducklake', path: 'main/orders_gold' })
expect(r.edges).toContainEqual({
runnable_path: 'f/x/prod',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: 'main/orders_gold',
access_type: 'w',
unsaved: true
})
// …the stale write edge is dropped, but the deployed dataset node stays.
expect(
r.edges.filter((e) => e.asset_path === 'main/orders' && e.runnable_path === 'f/x/prod')
).toEqual([])
expect(r.assets).toContainEqual({ kind: 'ducklake', path: 'main/orders' })
})
it('open saved script: unchanged materialize target dedups against the persisted write edge', () => {
const r = resolveGraph(
input({
base: deployedProducer(),
liveBodyAssets: { scriptPath: 'f/x/prod', assets: [] },
liveAnnotations: {
scriptPath: 'f/x/prod',
annotations: ann({
inPipeline: true,
materialize: { targetKind: 'ducklake', targetPath: 'main/orders' }
})
}
})
)
expect(
r.edges.filter(
(e) =>
e.runnable_path === 'f/x/prod' &&
e.asset_path === 'main/orders' &&
(e.access_type === 'w' || e.access_type === 'rw')
)
).toHaveLength(1)
})
it('open saved script: live body reads/writes overlay as unsaved lineage', () => {
const base = baseGraph({
assets: [{ kind: 'ducklake', path: 'main/orders' }],
runnables: [{ path: 'f/x/cons', usage_kind: 'script', in_pipeline: true }],
edges: [
{
runnable_path: 'f/x/cons',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: 'main/orders',
access_type: 'r'
}
]
})
const r = resolveGraph(
input({
base,
liveBodyAssets: {
scriptPath: 'f/x/cons',
assets: [duck('main/orders_eu', 'r'), s3('/report.parquet', 'w')]
},
liveAnnotations: {
scriptPath: 'f/x/cons',
annotations: ann({ inPipeline: true })
}
})
)
// New read + write from the buffer…
expect(r.edges).toContainEqual({
runnable_path: 'f/x/cons',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: 'main/orders_eu',
access_type: 'r',
unsaved: true
})
expect(r.edges).toContainEqual({
runnable_path: 'f/x/cons',
runnable_kind: 'script',
asset_kind: 's3object',
asset_path: '/report.parquet',
access_type: 'w',
unsaved: true
})
// …the no-longer-read persisted edge is dropped.
expect(
r.edges.filter((e) => e.asset_path === 'main/orders' && e.runnable_path === 'f/x/cons')
).toEqual([])
})
})
describe('inactive draft input lineage', () => {
it('keeps read edges + derived cascade for a deselected draft via inputAssets', () => {
const drafts = new Map([
[
'f/x/cons',
{
script: { content: '-- pipeline\n-- materialize ducklake://main/agg\nselect 1' },
outputAssets: [{ kind: 'ducklake' as const, path: 'main/agg' }],
inputAssets: [{ kind: 'ducklake' as const, path: 'main/src' }]
}
]
])
// No live overlay: the draft is NOT the open script.
const r = resolveGraph(input({ drafts }))
expect(r.assets).toContainEqual({ kind: 'ducklake', path: 'main/src' })
expect(r.edges).toContainEqual({
runnable_path: 'f/x/cons',
runnable_kind: 'script',
asset_kind: 'ducklake',
asset_path: 'main/src',
access_type: 'r',
unsaved: true
})
// Auto-derived cascade trigger from the captured read.
expect(assetTrigKeys(r, 'f/x/cons')).toContain('ducklake:main/src')
})
it('an empty inputAssets array is authoritative — no session-cache fallback', () => {
const drafts = new Map([
[
'f/x/cons',
{
script: { content: '-- pipeline\nselect 1' },
inputAssets: [] as Array<{ kind: 'ducklake'; path: string }>
}
]
])
const inferredReadsByPath = new Map([
['f/x/cons', [{ kind: 'ducklake' as const, path: 'main/stale' }]]
])
const r = resolveGraph(input({ drafts, inferredReadsByPath }))
expect(r.assets).not.toContainEqual({ kind: 'ducklake', path: 'main/stale' })
expect(r.edges.filter((e) => e.asset_path === 'main/stale')).toEqual([])
})
})
describe('open saved script: live overlay vs inferred-lineage maps', () => {
it('does not duplicate edges when the same live ref reaches both paths', () => {
// The route page mirrors the open pane's liveBodyAssets into
// inferredReads/WritesByPath, so the same refs arrive twice.
const base = baseGraph({
runnables: [{ path: 'f/x/cons', usage_kind: 'script', in_pipeline: true }]
})
const live = [duck('main/src', 'r'), s3('/out.parquet', 'w')]
const r = resolveGraph(
input({
base,
liveBodyAssets: { scriptPath: 'f/x/cons', assets: live },
liveAnnotations: { scriptPath: 'f/x/cons', annotations: ann({ inPipeline: true }) },
inferredReadsByPath: new Map([
['f/x/cons', [{ kind: 'ducklake' as const, path: 'main/src' }]]
]),
inferredWritesByPath: new Map([
['f/x/cons', [{ kind: 's3object' as const, path: '/out.parquet' }]]
])
})
)
expect(
r.edges.filter((e) => e.asset_path === 'main/src' && e.runnable_path === 'f/x/cons')
).toHaveLength(1)
expect(
r.edges.filter((e) => e.asset_path === '/out.parquet' && e.runnable_path === 'f/x/cons')
).toHaveLength(1)
})
})
describe('dbtAssociations', () => {
const runnables = [
{ usage_kind: 'script', path: 'f/a/dbtproj', dbt: { model_count: 2 } },
{ usage_kind: 'script', path: 'f/a/consumer' }
]
const edges = [
// the project builds a model and reads a source
{
runnable_kind: 'script',
runnable_path: 'f/a/dbtproj',
asset_kind: 'dbt',
asset_path: 'wh/s/model_a',
access_type: 'w'
},
{
runnable_kind: 'script',
runnable_path: 'f/a/dbtproj',
asset_kind: 'dbt',
asset_path: 'wh/s/src_a',
access_type: 'r'
},
// a native consumer reads the model — its edges are none of dbt's business
{
runnable_kind: 'script',
runnable_path: 'f/a/consumer',
asset_kind: 'dbt',
asset_path: 'wh/s/model_a',
access_type: 'r'
}
]
it('points every declared relation back at its project', () => {
const { ownerByAsset } = dbtAssociations(runnables, edges)
// A source is declared by the project too, so its badge finds it.
expect(ownerByAsset.get('asset:dbt:wh/s/src_a')).toBe('script:f/a/dbtproj')
expect(ownerByAsset.get('asset:dbt:wh/s/model_a')).toBe('script:f/a/dbtproj')
})
it('counts only what the project materializes as its transforms', () => {
const { writesByOwner } = dbtAssociations(runnables, edges)
// Hovering the project highlights the models it builds, not its inputs.
expect([...(writesByOwner.get('script:f/a/dbtproj') ?? [])]).toEqual([
'asset:dbt:wh/s/model_a'
])
})
it('ignores runnables that are not dbt', () => {
const { ownerByAsset } = dbtAssociations([runnables[1]], edges)
expect(ownerByAsset.size).toBe(0)
})
})
describe('dbtAssociations with a producer and a consumer of one relation', () => {
const runnables = [
{ usage_kind: 'script', path: 'f/a/producer', dbt: { model_count: 1 } },
{ usage_kind: 'script', path: 'f/a/reader', dbt: { model_count: 1 } }
]
const write = {
runnable_kind: 'script',
runnable_path: 'f/a/producer',
asset_kind: 'dbt',
asset_path: 'wh/s/shared',
access_type: 'w'
}
const read = {
runnable_kind: 'script',
runnable_path: 'f/a/reader',
asset_kind: 'dbt',
asset_path: 'wh/s/shared',
access_type: 'r'
}
// Backend edge order is unspecified, so the badge must not depend on it.
it('always points at the project that builds the relation', () => {
for (const edges of [
[write, read],
[read, write]
]) {
const { ownerByAsset } = dbtAssociations(runnables, edges)
expect(ownerByAsset.get('asset:dbt:wh/s/shared')).toBe('script:f/a/producer')
}
})
it('falls back to a reader when nothing here produces the relation', () => {
const { ownerByAsset } = dbtAssociations(runnables, [read])
expect(ownerByAsset.get('asset:dbt:wh/s/shared')).toBe('script:f/a/reader')
})
})
// Two selections of one project materializing the same model is a shape the
// backend permits, so the badge has to land on the same one every time — not on
// whichever write edge the backend happened to return last.
describe('dbtAssociations with two writers of one relation', () => {
const runnables = [
{ usage_kind: 'script', path: 'f/a/upstream', dbt: { model_count: 1 } },
{ usage_kind: 'script', path: 'f/a/downstream', dbt: { model_count: 1 } }
]
const writeBy = (p: string) => ({
runnable_kind: 'script',
runnable_path: p,
asset_kind: 'dbt',
asset_path: 'wh/s/shared',
access_type: 'w'
})
it('picks the same producer whatever order the edges arrive in', () => {
const a = writeBy('f/a/upstream')
const b = writeBy('f/a/downstream')
const first = dbtAssociations(runnables, [a, b]).ownerByAsset.get('asset:dbt:wh/s/shared')
const second = dbtAssociations(runnables, [b, a]).ownerByAsset.get('asset:dbt:wh/s/shared')
expect(first).toBe(second)
expect(first).toBe('script:f/a/downstream')
})
it('still credits both with materializing it', () => {
const { writesByOwner } = dbtAssociations(runnables, [
writeBy('f/a/upstream'),
writeBy('f/a/downstream')
])
expect(writesByOwner.get('script:f/a/upstream')).toEqual(new Set(['asset:dbt:wh/s/shared']))
expect(writesByOwner.get('script:f/a/downstream')).toEqual(
new Set(['asset:dbt:wh/s/shared'])
)
})
})