Skip to content

Bronze Layer

The bronze layer is Rocky’s config-driven replication inside the warehouse. You write no SQL. Rocky finds which tables are available, writes the SQL itself, and copies rows from the ingestion catalog into the target catalogs and schemas you declare.

rocky discover → rocky plan → rocky apply
  1. Discover. Finds the schemas and tables available for processing. A fivetran adapter calls the Fivetran REST API for connectors and enabled tables. A duckdb adapter queries information_schema. A manual adapter reads schema and table definitions written inline in the config.
  2. Plan. Parses the source schema names, resolves target catalogs and schemas, and writes the SQL statements. Records the result as a deterministic plan, keyed by plan_id.
  3. Apply. Runs one plan by id. It creates catalogs and schemas, copies data, runs quality checks, and updates watermarks. rocky run collapses plan and apply into one call, for local work and automation.

Source schemas follow a naming convention. Rocky splits the name into parts using a pattern you configure.

src__acme__us_west__shopify
│ │ │ │
│ │ │ └── source = "shopify" (the connector)
│ │ └── regions = ["us_west"] (variable-length)
│ └── tenant = "acme"
└── prefix "src__", stripped

Declare the pattern under the pipeline source in rocky.toml:

[pipeline.bronze.source.schema_pattern]
prefix = "src__"
separator = "__"
components = ["tenant", "regions...", "source"]

Templates on the pipeline target decide where the rows land.

[pipeline.bronze.target]
adapter = "prod"
catalog_template = "warehouse"
schema_template = "stage__{source}"

Rocky fills those templates from the parts it just parsed.

fivetran_catalog.src__acme__us_west__shopify.orders ← source table
└───────────┬─────────────┘
│ schema_pattern splits the schema name
▼
tenant = "acme"
regions = ["us_west"]
source = "shopify"
│ templates fill from those parts
│ catalog_template = "warehouse" (no variable)
│ schema_template = "stage__{source}"
▼
warehouse.stage__shopify.orders ← target table

For a multi-tenant setup where each tenant gets its own catalog, see Schema Patterns for the {tenant}_warehouse plus components = ["tenant", "regions...", "source"] pattern.

Set auto_create_catalogs and auto_create_schemas under the target’s governance block. Rocky then creates the target catalog and schema before it copies anything:

[pipeline.bronze.target.governance]
auto_create_catalogs = true
auto_create_schemas = true
[pipeline.bronze.target.governance.tags]
managed_by = "rocky"
CREATE CATALOG IF NOT EXISTS warehouse;
CREATE SCHEMA IF NOT EXISTS warehouse.stage__shopify;

Rocky applies the tags you declare in governance.tags to the catalogs it manages. A tag such as managed_by lets you find those catalogs later.

The first run has no watermark (the timestamp of the newest row Rocky has already copied), so it does a full refresh. Every run after that copies only the rows whose timestamp is newer than the stored watermark:

INSERT INTO warehouse.stage__shopify.orders
SELECT *, CAST(NULL AS STRING) AS _loaded_by
FROM fivetran_catalog.src__acme__us_west__shopify.orders
WHERE _fivetran_synced > TIMESTAMP '2026-04-17 09:30:00'

The literal in that WHERE is the previous run’s MAX(_fivetran_synced). Rocky keeps it in the state store and writes it into the query. It does not read the value back from the target with a subquery.

_fivetran_synced is Fivetran’s built-in column recording when each row was synced. Rocky uses it as the watermark column by default. Change it with timestamp_column.

When Rocky detects schema drift, it responds in graded steps. A safe type widening becomes ALTER COLUMN TYPE. A new column becomes ALTER TABLE ADD COLUMN. Only an unsafe type change falls back to a full refresh, which drops and recreates the target table.

Rocky can add metadata columns to the copied tables. Declare them on the pipeline, alongside strategy and timestamp_column:

[pipeline.bronze]
type = "replication"
strategy = "incremental"
timestamp_column = "_fivetran_synced"
metadata_columns = [
{ name = "_loaded_by", type = "STRING", value = "NULL" }
]

Rocky appends them to the SELECT: SELECT *, CAST(NULL AS STRING) AS _loaded_by.

Because value becomes part of that statement, Rocky checks it when the config loads: it must be one expression, and any function it calls must be on the scalar-function allowlist. See metadata_columns for what passes and what does not.

Scope a run to one tenant:

Terminal window
plan_id=$(rocky --config rocky.toml plan --filter tenant=acme --output json | jq -r .plan_id)
rocky apply "$plan_id"

This processes only the schemas whose parsed tenant component is acme.