dbt Core + DuckDB · warehouse & SQL

A GTM warehouse built with the tool the postings actually name

Staging → marts, an incremental fact table, generic and custom dbt tests, and a real/synthetic boundary marked everywhere it matters — not a hand-rolled SQLite script with a warehouse's name on it.

Why this exists, and why not just SQLite

An earlier project (gtm-warehouse-demo) built a four-table SQLite warehouse with hand-written SQL. This repo replaces that with the tool GTM Engineer postings actually name: dbt (staging → marts, tests, docs, incremental models) over an analytical engine — DuckDB here, though the same dbt project would point at BigQuery or Snowflake with a one-line profile change and no model changes. That portability is the reason dbt exists.

What's real, what's synthetic

Read this before citing any number from this repo. Signal data is real. Touch and opportunity data is synthetic, generated because real sequencing infrastructure doesn't exist yet in this portfolio. Reply rates, meeting counts and anything downstream of them are demonstrations of the warehouse's shape, not real outcomes.

DataStatusSource
raw_fda_signals.csv — 34 firmsREALExtracted from n8n-gtm-orchestration's committed native execution record
synthetic_touches.csv — 15 touchesSYNTHETICGenerated, fixed seed 20260823
synthetic_opportunities.csv — 2 oppsSYNTHETICGenerated, fixed seed, reply probability weighted by score, capped 35%

Two scripts make the boundary reproducible rather than asserted — both regenerate the committed seeds byte-for-byte:

python scripts/extract_real_signals.py      # regenerates the REAL seed from the n8n repo
python scripts/generate_synthetic_seeds.py  # regenerates the SYNTHETIC seeds, same fixed seed

The model

seeds (raw_fda_signals REAL, synthetic_touches/opportunities SYNTHETIC)
        │
        ▼
stg_fda_signals · stg_touches · stg_opportunities        (staging: cast, rename, clean)
        │
        ▼
dim_account            one row per firm — real signal columns, synthetic touch/opp counts
fact_signal            incremental — real, one row per (firm, batch_date)
fact_touch             synthetic
fact_opportunity       synthetic
funnel_summary         signal counts real, downstream stages synthetic

fact_signal is the incremental model. Only one signal batch exists today, so an incremental run and a full-refresh currently produce the same table — the pattern is demonstrated at the scale actually available, not dressed up to look bigger. In production, each scheduled n8n run would append a new batch, and is_incremental() means this model only scores what's new instead of reprocessing history on every run.

Deliberately does not re-implement multi-touch attribution. That already exists, built properly, in revenue-engine-system (4 models compared on its own disclosed data). Duplicating it here, on a smaller and mostly-synthetic sample, would be the same claim made twice, once weaker.

Tests — 46/46 passing, zero warnings

export DBT_PROFILES_DIR="$PWD"
dbt build
Finished running 1 incremental model, 3 seeds, 4 table models, 35 data tests,
3 view models in 1.06s
Done. PASS=46 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=46

Full run log committed at output/dbt-build-log.txt. 35 data tests: the standard set (unique, not_null, relationships, accepted_values) plus two written for this project:

All seven MissingArgumentsPropertyInGenericTestDeprecation warnings dbt raised on the first build were fixed (test arguments moved under the current arguments: nesting) rather than left as noise. dbt build now runs clean.

Source freshness — specified, not faked

models/staging/_staging.yml documents a freshness: block on the raw_fda_signals source, commented out with an explanation: freshness compares a source's last-loaded timestamp against a schedule, and this project's data arrives as a committed seed (for reproducibility), not a live nightly load. Turning that check on for real is exactly the config shown, uncommented, the day this table is fed by a schedule instead of a seed. Writing a check that would always silently pass would be worse than not having one.

Reproduce it

python3 -m venv .venv
./.venv/bin/pip install dbt-core dbt-duckdb
export DBT_PROFILES_DIR="$PWD"
./.venv/bin/dbt build
dbt docs generate && dbt docs serve   # interactive lineage DAG in a browser

Tested with dbt-core 1.10.23 and dbt-duckdb 1.10.0 on Python 3.9.6 (macOS system Python). No API key, no cloud account, no cost.

What this doesn't prove

Files

dbt_project.yml, profiles.yml          project + connection config
seeds/raw_fda_signals.csv              REAL — 34 firms from the n8n execution record
seeds/synthetic_*.csv                  SYNTHETIC — see above
scripts/extract_real_signals.py        regenerates the real seed from n8n-gtm-orchestration
scripts/generate_synthetic_seeds.py    regenerates the synthetic seeds (fixed seed)
models/staging/                        cast/rename/clean, one model per source
models/marts/                          dim_account, fact_signal (incremental), fact_touch,
                                        fact_opportunity, funnel_summary
macros/test_value_in_range.sql         custom generic test
tests/assert_briefed_firms_cleared_threshold.sql   singular cross-artifact test
output/dbt-build-log.txt               committed real `dbt build` output

Built something similar for your stack?

I build signal-detection, enrichment and outbound systems inside your own tools. You own everything — nothing is locked to me.