Postgres API
Every public type in carbon_core::postgres — processors, row types, CRUD traits, migrations, metadata columns and sqlx primitives.
On this page
- Two storage models
- Processors
- PostgresAccountProcessor
- PostgresInstructionProcessor
- PostgresJsonAccountProcessor
- PostgresJsonInstructionProcessor
- register_
postgres_ metrics - Rows
- AccountRow
- InstructionRow
- Instruction primary key
- GenericAccountsMigration
- GenericInstructionMigration
- AccountRowMigrationOperation
- InstructionRowMigrationOperation
- Operations
- Table
- Insert
- Upsert
- Delete
- Lookup
- Metadata
- AccountRowMetadata
- InstructionRowMetadata
- Primitives
- Pubkey
- U8, U16, U32
- U64, U128
- I128
- Generated decoder types
- Examples
- Example: typed tables
- Example: generic JSONB tables
- Example: custom row
carbon_ persists decoded accounts and instructions to PostgreSQL through sqlx. Use this page when you write a Postgres-backed processor, query the tables Carbon creates, or implement your own row type. For a walkthrough, see Store data in Postgres.
The module is behind the postgres feature of carbon-core:
[dependencies]
carbon-core = { version = "2.0.0", features = ["postgres"] }
# A generated decoder with its own typed tables:
carbon-meteora-dlmm-decoder = { version = "2.0.0", features = ["postgres"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres"] }
sqlx_migrator = { version = "0.17.0", features = ["postgres"] }
async-trait = "0.1.89"The module has five submodules:
| Module | Contents |
|---|---|
processors | Four Processor implementations that upsert every decoded update. |
rows | AccountRow< and InstructionRow< (JSONB tables) and their migrations. |
operations | The Table, Insert, Upsert, Delete and Lookup traits. |
metadata | The __-prefixed metadata columns every row carries. |
primitives | sqlx newtypes for Pubkey and unsigned and 128-bit integers. |
Two storage models#
Carbon offers two ways to store data. Pick one per pipeline.
| Typed tables | Generic JSONB tables | |
|---|---|---|
| Processors | PostgresAccountProcessor, PostgresInstructionProcessor | PostgresJsonAccountProcessor, PostgresJsonInstructionProcessor |
| Tables | One per account type (<) and instruction type (<), with one column per field | accounts and instructions, decoded body in a JSONB column |
| Migrations | <, < from the decoder crate | GenericAccountsMigration, GenericInstructionMigration |
| Requires | Decoder built with its postgres feature | Decoded type implements serde:: and Deserialize (the decoder's serde feature) |
| Works with GraphQL | Yes, the decoder's generated QueryRoot reads these tables | No generated schema; write your own queries |
Processors#
carbon_. Source: processors..
Every processor:
- calls
upsertonce per decoded update and returns its error, so a failed write is logged by the pipeline and counted incarbon_; the pipeline keeps running;updates_ failed_ total - counts successes and failures and records the upsert duration in milliseconds. See Postgres metrics for the metric names;
- holds a
sqlx::(cheap to clone; passPgPool pool.to each processor).clone()
PostgresAccountProcessor#
Upserts each decoded account through a wrapper type W that knows how to write it.
pub struct PostgresAccountProcessor<T, W> { /* private fields */ }
impl<T, W> PostgresAccountProcessor<T, W> {
pub fn new(pool: sqlx::PgPool) -> Self
}
impl<'a, T, W> Processor<AccountProcessorInputType<'a, T>> for PostgresAccountProcessor<T, W>
where
T: Clone + Send + Sync + 'static,
W: From<(T, AccountMetadata)> + Upsert + Send + 'static,Details. For every update the processor clones decoded_ and the AccountMetadata, builds W:: and calls W::. Lamports, owner and the raw account bytes are not passed to W.
| Type parameter | Description |
|---|---|
T | The decoder's account type, for example MeteoraDlmmAccount. |
W | The row wrapper. Generated decoders provide <, which dispatches to one typed row per account variant. |
Example. Register a generated decoder's typed account tables:
.account(
MeteoraDlmmDecoder,
PostgresAccountProcessor::<MeteoraDlmmAccount, MeteoraDlmmAccountWithMetadata>::new(pool.clone()),
)See the complete program and a hand-written wrapper.
See also. Upsert, Generated decoder types, PostgresJsonAccountProcessor.
PostgresInstructionProcessor#
Upserts each decoded instruction through a wrapper type W.
pub struct PostgresInstructionProcessor<T, W> { /* private fields */ }
impl<T, W> PostgresInstructionProcessor<T, W> {
pub fn new(pool: sqlx::PgPool) -> Self
}
impl<'a, T, W> Processor<InstructionProcessorInputType<'a, T>> for PostgresInstructionProcessor<T, W>
where
T: Clone + Send + Sync + 'static,
W: From<(T, InstructionMetadata, Vec<AccountMeta>)> + Upsert + Send + 'static,Details. Builds W:: and calls upsert. The third tuple element is the instruction's full Vec<; generated rows store it in an __accounts JSONB column. Nested instructions are not passed to W; each inner instruction reaches the processor as its own update.
| Type parameter | Description |
|---|---|
T | The decoder's instruction enum, for example MeteoraDlmmInstruction. |
W | The row wrapper, for example the generated MeteoraDlmmInstructionWithMetadata. |
Example.
.instruction(
MeteoraDlmmDecoder,
PostgresInstructionProcessor::<MeteoraDlmmInstruction, MeteoraDlmmInstructionWithMetadata>::new(pool.clone()),
)See also. Instruction primary key, InstructionRowMetadata.
PostgresJsonAccountProcessor#
Upserts each decoded account into the generic accounts table as JSONB.
pub struct PostgresJsonAccountProcessor<T> { /* private fields */ }
impl<T> PostgresJsonAccountProcessor<T> {
pub fn new(pool: sqlx::PgPool) -> Self
}
impl<'a, T> Processor<AccountProcessorInputType<'a, T>> for PostgresJsonAccountProcessor<T>
where
T: serde::Serialize + for<'de> serde::Deserialize<'de> + Clone + Send + Sync + Unpin + 'static,Details. Builds an AccountRow< and calls its upsert, which runs INSERT INTO accounts (_. Each pubkey keeps one row holding its latest state; the update with the higher slot does not win automatically, the last one processed does. Create the table first with GenericAccountsMigration. Generated account enums serialize as {"type": "<, so filter with data->>'type' = 'LbPair'. Pubkey fields inside the JSON are arrays of 32 numbers.
Example.
.account(
TokenProgramDecoder,
PostgresJsonAccountProcessor::<TokenProgramAccount>::new(pool.clone()),
)See also. AccountRow, Two storage models.
PostgresJsonInstructionProcessor#
Upserts each decoded instruction into the generic instructions table as JSONB.
pub struct PostgresJsonInstructionProcessor<T> { /* private fields */ }
impl<T> PostgresJsonInstructionProcessor<T> {
pub fn new(pool: sqlx::PgPool) -> Self
}
impl<'a, T> Processor<InstructionProcessorInputType<'a, T>> for PostgresJsonInstructionProcessor<T>
where
T: serde::Serialize + for<'de> serde::Deserialize<'de> + Clone + Send + Sync + Unpin + 'static,Details. Builds an InstructionRow< and upserts it on (_. The instruction's accounts are not stored separately; they are in the JSON only if the decoded type includes them (generated instruction enums do, under accounts). Create the table with GenericInstructionMigration. See the complete program.
Example.
.instruction(
MeteoraDlmmDecoder,
PostgresJsonInstructionProcessor::<MeteoraDlmmInstruction>::new(pool.clone()),
)See also. Instruction primary key, InstructionRow.
register_ postgres_ metrics#
Registers the six Postgres metrics with the global MetricsRegistry.
pub fn register_postgres_metrics()Details. PipelineBuilder:: calls it whenever carbon-core is compiled with postgres, so you never call it yourself. The metrics therefore appear in every exporter's output when the feature is on, even if no Postgres processor is registered. Calling it again registers the metrics a second time and duplicates them in snapshots.
Example.
// Only needed if you read MetricsRegistry::global() without building a pipeline.
carbon_core::postgres::processors::register_postgres_metrics();See also. Metrics reference.
Rows#
carbon_. Source: rows.rs.
The generic tables these rows map to:
CREATE TABLE IF NOT EXISTS accounts (
__pubkey BYTEA NOT NULL,
__slot NUMERIC,
data JSONB NOT NULL,
PRIMARY KEY (__pubkey)
);
CREATE TABLE IF NOT EXISTS instructions (
__signature TEXT NOT NULL,
__instruction_index BIGINT NOT NULL,
__stack_height BIGINT NOT NULL,
__slot NUMERIC,
decoded_instruction JSONB NOT NULL,
PRIMARY KEY (__signature, __instruction_index, __stack_height)
);AccountRow#
One row of the generic accounts table.
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct AccountRow<T>
where
T: serde::Serialize + for<'de> serde::Deserialize<'de> + Clone + Send + Sync + Unpin + 'static,
{
#[sqlx(flatten)]
pub metadata: AccountRowMetadata,
pub data: sqlx::types::Json<T>,
}
impl<T> AccountRow<T> {
pub fn from_parts(source: T, metadata: AccountMetadata) -> Self
}Details.
| Field | Type | Column | Description |
|---|---|---|---|
metadata | AccountRowMetadata | __pubkey, __slot | Account address and slot. |
data | sqlx:: | data | The decoded account, serialized with serde_json. |
Trait implementations:
| Trait | SQL | Key |
|---|---|---|
Insert | INSERT INTO accounts …; fails on an existing pubkey | — |
Upsert | … ON CONFLICT (_ | — |
Delete | DELETE FROM accounts WHERE _ | primitives:: |
Lookup | SELECT * FROM accounts WHERE _ | primitives:: |
Database errors are returned as Error:: with the sqlx message.
Example. Read back what PostgresJsonAccountProcessor wrote:
use carbon_core::postgres::{operations::Lookup, primitives::Pubkey, rows::AccountRow};
let row = AccountRow::<MeteoraDlmmAccount>::lookup(Pubkey(pubkey), &pool).await?;
if let Some(row) = row {
println!("slot={:?} data={:?}", row.metadata.slot, row.data.0);
}See also. PostgresJsonAccountProcessor, GenericAccountsMigration.
InstructionRow#
One row of the generic instructions table.
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct InstructionRow<T>
where
T: serde::Serialize + for<'de> serde::Deserialize<'de> + Clone + Send + Sync + Unpin + 'static,
{
#[sqlx(flatten)]
pub metadata: InstructionRowMetadata,
pub decoded_instruction: sqlx::types::Json<T>,
}
impl<T> InstructionRow<T> {
pub fn from_parts(decoded_instruction: T, metadata: InstructionMetadata) -> Self
}Details.
| Field | Type | Column | Description |
|---|---|---|---|
metadata | InstructionRowMetadata | __signature, _, _, __slot | Where the instruction sits. |
decoded_ | sqlx:: | decoded_ | The decoded instruction as JSON. |
Insert, Upsert (ON CONFLICT (_), Delete and Lookup are implemented. The Delete and Lookup key is (String, U32, U32): signature in base58, instruction index, stack height.
Example.
use carbon_core::postgres::{operations::Lookup, rows::InstructionRow};
// Top-level instruction 0 of a transaction.
let key = (signature.to_string(), 0u32.into(), 1u32.into());
let row = InstructionRow::<MeteoraDlmmInstruction>::lookup(key, &pool).await?;See also. Instruction primary key, GenericInstructionMigration.
Instruction primary key#
Instruction tables, generic and generated, use the primary key (_. _ comes from InstructionMetadata::, which for an inner instruction is the index of its top-level instruction, not its position among siblings.
GenericAccountsMigration#
sqlx_ migration that creates the generic accounts table.
pub struct GenericAccountsMigration;
impl sqlx_migrator::Migration<sqlx::Postgres> for GenericAccountsMigrationDetails. App "carbon_, name "generic_, no parents, one operation: AccountRowMigrationOperation. Rolling it back drops the table.
Example.
use sqlx_migrator::{Info, Migrate, Plan};
let mut migrator = sqlx_migrator::Migrator::default();
migrator
.add_migration(Box::new(GenericAccountsMigration))
.map_err(|e| Error::Custom(e.to_string()))?;
let mut conn = pool.acquire().await.map_err(|e| Error::Custom(e.to_string()))?;
migrator
.run(&mut *conn, &Plan::apply_all())
.await
.map_err(|e| Error::Custom(e.to_string()))?;See also. GenericInstructionMigration.
GenericInstructionMigration#
sqlx_ migration that creates the generic instructions table.
pub struct GenericInstructionMigration;
impl sqlx_migrator::Migration<sqlx::Postgres> for GenericInstructionMigrationDetails. App "carbon_, name "generic_, no parents, one operation: InstructionRowMigrationOperation.
Example.
migrator
.add_migration(Box::new(GenericInstructionMigration))
.map_err(|e| Error::Custom(e.to_string()))?;See also. GenericAccountsMigration.
AccountRowMigrationOperation#
The sqlx_ behind GenericAccountsMigration.
pub struct AccountRowMigrationOperation;
impl sqlx_migrator::Operation<sqlx::Postgres> for AccountRowMigrationOperationDetails. up runs CREATE TABLE IF NOT EXISTS accounts (…) (see Rows); down runs DROP TABLE IF EXISTS accounts. Use it to include the table in a migration of your own.
Example.
fn operations(&self) -> Vec<Box<dyn sqlx_migrator::Operation<sqlx::Postgres>>> {
vec![Box::new(AccountRowMigrationOperation)]
}See also. InstructionRowMigrationOperation.
InstructionRowMigrationOperation#
The sqlx_ behind GenericInstructionMigration.
pub struct InstructionRowMigrationOperation;
impl sqlx_migrator::Operation<sqlx::Postgres> for InstructionRowMigrationOperationDetails. up creates the instructions table; down runs DROP TABLE IF EXISTS instructions.
Example.
fn operations(&self) -> Vec<Box<dyn sqlx_migrator::Operation<sqlx::Postgres>>> {
vec![Box::new(InstructionRowMigrationOperation)]
}See also. AccountRowMigrationOperation.
Operations#
carbon_. Source: operations.. Insert, Upsert, Delete and Lookup are #[async_ traits: annotate your impls with #[async_.
Table#
Static table name and column list.
pub trait Table {
fn table() -> &'static str;
fn columns() -> Vec<&'static str>;
}Details. Nothing in carbon-core calls it. Generated decoders implement it on each decoded type (not the row), for example Swap:: returns "swap_. Use it to build queries without hard-coding names.
Example.
use carbon_core::postgres::operations::Table;
use carbon_meteora_dlmm_decoder::instructions::swap::Swap;
let sql = format!("SELECT count(*) FROM {}", Swap::table());See also. Generated decoder types.
Insert#
Inserts a row, failing if the primary key exists.
#[async_trait::async_trait]
pub trait Insert {
async fn insert(&self, pool: &sqlx::PgPool) -> CarbonResult<()>;
}Details. No built-in processor calls insert; they all use Upsert. Implemented by AccountRow, InstructionRow and every generated row and wrapper.
Example.
AccountRow::from_parts(decoded, metadata).insert(&pool).await?;See also. Upsert.
Upsert#
Inserts a row or updates it on primary-key conflict.
#[async_trait::async_trait]
pub trait Upsert {
async fn upsert(&self, pool: &sqlx::PgPool) -> CarbonResult<()>;
}Details. The bound on W in PostgresAccountProcessor and PostgresInstructionProcessor. Return Ok(()) without writing to skip an update.
Example. See a custom row.
See also. Insert.
Delete#
Deletes the row matching a key.
#[async_trait::async_trait]
pub trait Delete {
type Key;
async fn delete(key: Self::Key, pool: &sqlx::PgPool) -> CarbonResult<()>;
}Details. An associated function, not a method: call AccountRow::. No built-in processor deletes rows; account closures arrive as AccountDeletion updates, which you handle with a account_ processor that calls delete.
Example.
use carbon_core::postgres::{operations::Delete, rows::AccountRow};
AccountRow::<MeteoraDlmmAccount>::delete(deletion.pubkey.into(), &pool).await?;See also. Lookup.
Lookup#
Selects the row matching a key, or None.
#[async_trait::async_trait]
pub trait Lookup: Sized {
type Key;
async fn lookup(key: Self::Key, pool: &sqlx::PgPool) -> CarbonResult<Option<Self>>;
}Details. Generated account rows implement it with Key = primitives::; the generated GraphQL QueryRoot uses it for single-account fields.
Example. See AccountRow.
See also. Delete.
Metadata#
carbon_. Source: metadata.rs. Row types embed these with #[sqlx(flatten)]; the __ prefix keeps metadata columns from colliding with program field names.
AccountRowMetadata#
Account metadata columns.
#[derive(Debug, Clone, PartialEq, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
pub struct AccountRowMetadata {
#[sqlx(rename = "__pubkey")]
pub pubkey: Pubkey,
#[sqlx(rename = "__slot")]
pub slot: Option<U64>,
}
impl From<AccountMetadata> for AccountRowMetadataDetails.
| Field | Type | Column | Postgres type | Description |
|---|---|---|---|---|
pubkey | Pubkey | __pubkey | BYTEA (32 bytes) | Account address. |
slot | Option<U64> | __slot | NUMERIC | Slot of the update. Always Some when converted from AccountMetadata. |
AccountMetadata:: is not stored.
Example.
let columns: AccountRowMetadata = input.metadata.clone().into();See also. InstructionRowMetadata.
InstructionRowMetadata#
Instruction metadata columns.
#[derive(Debug, Clone, PartialEq, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
pub struct InstructionRowMetadata {
#[sqlx(rename = "__signature")]
pub signature: String,
#[sqlx(rename = "__instruction_index")]
pub instruction_index: U32,
#[sqlx(rename = "__stack_height")]
pub stack_height: U32,
#[sqlx(rename = "__slot")]
pub slot: Option<U64>,
}
impl From<InstructionMetadata> for InstructionRowMetadataDetails.
| Field | Type | Column | Postgres type | Description |
|---|---|---|---|---|
signature | String | __signature | TEXT | Transaction signature, base58. |
instruction_ | U32 | _ | BIGINT | InstructionMetadata::: the top-level instruction's index, also for inner instructions. |
stack_ | U32 | _ | BIGINT | 1 for top-level instructions, 2+ for CPIs. |
slot | Option<U64> | __slot | NUMERIC | Transaction slot. Always Some when converted. |
absolute_, block time and the rest of TransactionMetadata are not stored.
Example.
let columns: InstructionRowMetadata = input.metadata.clone().into();See also. Instruction primary key.
Primitives#
carbon_. Source: primitives.. Postgres has no unsigned integers and no 32-byte key type, so these newtypes implement sqlx Type, Encode, Decode and PgHasArrayType with a lossless mapping. Each derefs to its inner value, is a single public tuple field, and serializes transparently with serde.
| Type | Wraps | Postgres type | Inner value |
|---|---|---|---|
Pubkey | solana_ | BYTEA | solana_ |
U8 | u8 | INT2 | i16 |
U16 | u16 | INT4 | i32 |
U32 | u32 | INT8 | i64 |
U64 | u64 | NUMERIC | u64 |
U128 | u128 | NUMERIC | u128 |
I128 | i128 | NUMERIC | i128 |
Signed types up to 64 bits use sqlx's own i16/i32/i64 mappings directly.
Pubkey#
A Solana public key stored as 32 raw bytes.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct Pubkey(pub solana_pubkey::Pubkey);
impl From<solana_pubkey::Pubkey> for Pubkey
impl From<Vec<u8>> for Pubkey
impl From<&[u8]> for Pubkey
impl Deref for Pubkey { type Target = solana_pubkey::Pubkey; }Details. Encodes as BYTEA, arrays as BYTEA[]. Decoding fails if the column does not hold exactly 32 bytes. From< and From<&[u8]> return the all-zero key 11111111111111111111111111111111 for input of the wrong length instead of failing. It is a different type from carbon_.
Example.
let key: carbon_core::postgres::primitives::Pubkey = solana_pubkey::Pubkey::new_unique().into();
println!("{}", *key); // Deref to solana_pubkey::PubkeySee also. AccountRowMetadata.
U8, U16, U32#
Unsigned integers stored in the next wider signed column.
pub struct U8(pub i16); // INT2
pub struct U16(pub i32); // INT4
pub struct U32(pub i64); // INT8
// For each of (U8, u8, i16), (U16, u16, i32), (U32, u32, i64):
impl From<u8> for U8
impl From<i16> for U8
impl From<&i16> for U8
impl TryFrom<U8> for u8 { type Error = carbon_core::error::Error; }
impl Deref for U8 { type Target = i16; }Details. The inner field is the signed wider type, so *U32 is an i64. TryFrom back to the unsigned type returns Error:: if the stored value does not fit, which only happens when the column was written by something other than Carbon. Derives Clone, Copy, PartialEq, Eq, Debug.
Example.
let decimals: U8 = 255u8.into(); // stored as INT2 255
let back: u8 = decimals.try_into()?;See also. U64, U128.
U64, U128#
Unsigned 64- and 128-bit integers stored as NUMERIC.
pub struct U64(pub u64);
pub struct U128(pub u128);
// For each of (U64, u64), (U128, u128):
impl From<u64> for U64
impl TryFrom<U64> for u64 { type Error = carbon_core::error::Error; } // always Ok
impl Deref for U64 { type Target = u64; }
impl From<U64> for sqlx::types::Decimal
impl From<sqlx::types::Decimal> for U64
impl From<&sqlx::types::Decimal> for U64Details. Encodes through BigDecimal as a binary NUMERIC, so the full range survives (u64::MAX round-trips). Decoding fails with Error:: when the stored value has a fractional part or a minus sign ("invalid sign/) or exceeds the type's maximum ("value exceeds maximum for target type"). The From< conversions are infallible: values that are negative, fractional or out of range become 0 silently. From< also returns 0 for values a rust_ cannot hold (above about 7.9 × 10²⁸, only reachable with U128). Derives Clone, Debug, PartialEq, Eq (not Copy; bind with &row.field).
Example.
let supply: U64 = u64::MAX.into();
let raw: u64 = *supply;See also. I128.
I128#
Signed 128-bit integer stored as NUMERIC.
pub struct I128(pub i128);
impl From<i128> for I128
impl Deref for I128 { type Target = i128; }Details. Same NUMERIC encoding as U128. Decoding fails with Error:: for fractional values and "invalid numeric string" when out of range. Unlike U64/U128, there is no TryFrom< and no Decimal conversion; use *value or .0.
Example.
let delta = I128::from(-1i128);See also. U64, U128.
Generated decoder types#
A decoder built with its postgres feature (for example carbon-meteora-dlmm-decoder, generated by the CLI) adds these public items. Names use the program's PascalCase name (MeteoraDlmm) and each type's snake_case name.
| Item | Path | Description |
|---|---|---|
< | accounts:: | Tuple struct (Account enum, AccountMetadata). From, Insert, Upsert; dispatches to the variant's row. Use as W in PostgresAccountProcessor. |
< | accounts:: | sqlx_ migration creating every < table. App name is the program's kebab-case name. |
< | accounts:: | Typed row: flattened AccountRowMetadata plus one column per field. from_, TryFrom<, Insert, Upsert, Delete and Lookup keyed by Pubkey. |
< | accounts:: | Creates one account table. |
< | instructions:: | Tuple struct (Instruction enum, InstructionMetadata, Vec<. Use as W in PostgresInstructionProcessor. |
< | instructions:: | Creates every < table. |
< | instructions:: | Flattened InstructionRowMetadata, one column per argument, and _. |
< | instructions:: | Creates one instruction table. |
impl Table for < | on the decoded type | Table name (swap_, lb_) and column list. |
Generated columns use NUMERIC(20) for u64, NUMERIC(39) for u128, NUMERIC(38) for i128 (values with 39 digits, above 10³⁸, fail to insert), INT2/INT4/INT8 for u8/u16/u32, BYTEA for pubkeys and byte arrays, and JSONB for nested structs and enums.
Examples#
Example: typed tables#
Indexes Meteora DLMM accounts and instructions into per-type tables. It uses the public RPC transaction crawler, so only DATABASE_ is required.
[dependencies]
carbon-core = { version = "2.0.0", features = ["postgres"] }
carbon-log-metrics = "2.0.0"
carbon-meteora-dlmm-decoder = { version = "2.0.0", features = ["postgres"] }
carbon-rpc-transaction-crawler-datasource = "2.0.0"
env_logger = "0.11.8"
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres"] }
sqlx_migrator = { version = "0.17.0", features = ["postgres"] }
tokio = { version = "1.48.0", features = ["full"] }use {
carbon_core::{
error::{CarbonResult, Error},
pipeline::Pipeline,
postgres::processors::{PostgresAccountProcessor, PostgresInstructionProcessor},
},
carbon_log_metrics::LogMetrics,
carbon_meteora_dlmm_decoder::{
accounts::{
postgres::{MeteoraDlmmAccountWithMetadata, MeteoraDlmmAccountsMigration},
MeteoraDlmmAccount,
},
instructions::{
postgres::{MeteoraDlmmInstructionWithMetadata, MeteoraDlmmInstructionsMigration},
MeteoraDlmmInstruction,
},
MeteoraDlmmDecoder, PROGRAM_ID,
},
carbon_rpc_transaction_crawler_datasource::{ConnectionConfig, Filters, RpcTransactionCrawler},
sqlx_migrator::{Info, Migrate, Plan},
std::sync::Arc,
};
#[tokio::main]
async fn main() -> CarbonResult<()> {
env_logger::init();
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| Error::Custom("DATABASE_URL must be set".to_string()))?;
let pool = sqlx::PgPool::connect(&database_url)
.await
.map_err(|e| Error::Custom(e.to_string()))?;
// Create one table per account type and per instruction type.
let mut migrator = sqlx_migrator::Migrator::default();
migrator
.add_migration(Box::new(MeteoraDlmmAccountsMigration))
.map_err(|e| Error::Custom(e.to_string()))?;
migrator
.add_migration(Box::new(MeteoraDlmmInstructionsMigration))
.map_err(|e| Error::Custom(e.to_string()))?;
let mut conn = pool.acquire().await.map_err(|e| Error::Custom(e.to_string()))?;
migrator
.run(&mut *conn, &Plan::apply_all())
.await
.map_err(|e| Error::Custom(e.to_string()))?;
let datasource = RpcTransactionCrawler::new(
"https://api.mainnet-beta.solana.com".to_string(),
PROGRAM_ID,
ConnectionConfig::default(),
Filters::new(None, None, None),
None,
);
Pipeline::builder()
.datasource(datasource)
.metrics(Arc::new(LogMetrics::new()))
.account(
MeteoraDlmmDecoder,
PostgresAccountProcessor::<MeteoraDlmmAccount, MeteoraDlmmAccountWithMetadata>::new(
pool.clone(),
),
)
.instruction(
MeteoraDlmmDecoder,
PostgresInstructionProcessor::<
MeteoraDlmmInstruction,
MeteoraDlmmInstructionWithMetadata,
>::new(pool.clone()),
)
.build()?
.run()
.await
}DATABASE_URL=postgres://postgres:postgres@localhost:5432/carbon cargo runThe transaction crawler emits transactions only, so the account tables stay empty here; pair the same processors with an account datasource such as rpc-program-subscribe to fill them. Then query a table:
SELECT __signature, amount_in, min_amount_out FROM swap_instruction ORDER BY __slot DESC LIMIT 5;Example: generic JSONB tables#
The same pipeline writing into the two generic tables. Replace the migrations and the processor:
use {
carbon_core::{
error::{CarbonResult, Error},
pipeline::Pipeline,
postgres::{
processors::PostgresJsonInstructionProcessor,
rows::{GenericAccountsMigration, GenericInstructionMigration},
},
},
carbon_log_metrics::LogMetrics,
carbon_meteora_dlmm_decoder::{
instructions::MeteoraDlmmInstruction, MeteoraDlmmDecoder, PROGRAM_ID,
},
carbon_rpc_transaction_crawler_datasource::{ConnectionConfig, Filters, RpcTransactionCrawler},
sqlx_migrator::{Info, Migrate, Plan},
std::sync::Arc,
};
#[tokio::main]
async fn main() -> CarbonResult<()> {
env_logger::init();
let database_url = std::env::var("DATABASE_URL")
.map_err(|_| Error::Custom("DATABASE_URL must be set".to_string()))?;
let pool = sqlx::PgPool::connect(&database_url)
.await
.map_err(|e| Error::Custom(e.to_string()))?;
// Creates the generic `accounts` and `instructions` tables.
let mut migrator = sqlx_migrator::Migrator::default();
migrator
.add_migration(Box::new(GenericAccountsMigration))
.map_err(|e| Error::Custom(e.to_string()))?;
migrator
.add_migration(Box::new(GenericInstructionMigration))
.map_err(|e| Error::Custom(e.to_string()))?;
let mut conn = pool.acquire().await.map_err(|e| Error::Custom(e.to_string()))?;
migrator
.run(&mut *conn, &Plan::apply_all())
.await
.map_err(|e| Error::Custom(e.to_string()))?;
let datasource = RpcTransactionCrawler::new(
"https://api.mainnet-beta.solana.com".to_string(),
PROGRAM_ID,
ConnectionConfig::default(),
Filters::new(None, None, None),
None,
);
Pipeline::builder()
.datasource(datasource)
.metrics(Arc::new(LogMetrics::new()))
.instruction(
MeteoraDlmmDecoder,
PostgresJsonInstructionProcessor::<MeteoraDlmmInstruction>::new(pool.clone()),
)
.build()?
.run()
.await
}Generated instruction enums serialize as {"type": "<. Pubkeys inside the JSON are arrays of 32 numbers, not base58 strings.
SELECT __signature, decoded_instruction->'data'->'data'->>'amount_in' AS amount_in
FROM instructions
WHERE decoded_instruction->>'type' = 'Swap'
LIMIT 5;Example: custom row#
A hand-written wrapper that stores only SPL Token mints in a table of your own. It shows Table, Upsert, Lookup, Delete and the primitives together. Create the table yourself:
CREATE TABLE mints (pubkey BYTEA PRIMARY KEY, slot NUMERIC NOT NULL, supply NUMERIC NOT NULL, decimals INT2 NOT NULL);use {
carbon_core::{
account::AccountMetadata,
error::{CarbonResult, Error},
postgres::{
operations::{Delete, Lookup, Table, Upsert},
primitives::{Pubkey, U64, U8},
processors::PostgresAccountProcessor,
},
},
carbon_token_program_decoder::accounts::TokenProgramAccount,
};
/// One row of a `mints` table. Non-mint accounts map to `None` and are skipped.
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct MintRow {
pub pubkey: Pubkey,
pub slot: U64,
pub supply: U64,
pub decimals: U8,
}
pub struct MaybeMint(Option<MintRow>);
impl From<(TokenProgramAccount, AccountMetadata)> for MaybeMint {
fn from((account, metadata): (TokenProgramAccount, AccountMetadata)) -> Self {
match account {
TokenProgramAccount::Mint(mint) => MaybeMint(Some(MintRow {
pubkey: metadata.pubkey.into(),
slot: metadata.slot.into(),
supply: mint.supply.into(),
decimals: mint.decimals.into(),
})),
_ => MaybeMint(None),
}
}
}
impl Table for MintRow {
fn table() -> &'static str {
"mints"
}
fn columns() -> Vec<&'static str> {
vec!["pubkey", "slot", "supply", "decimals"]
}
}
#[async_trait::async_trait]
impl Upsert for MaybeMint {
async fn upsert(&self, pool: &sqlx::PgPool) -> CarbonResult<()> {
let Some(row) = &self.0 else { return Ok(()) };
sqlx::query(
"INSERT INTO mints (pubkey, slot, supply, decimals) VALUES ($1, $2, $3, $4)
ON CONFLICT (pubkey) DO UPDATE SET slot = $2, supply = $3, decimals = $4",
)
.bind(row.pubkey)
.bind(&row.slot)
.bind(&row.supply)
.bind(row.decimals)
.execute(pool)
.await
.map_err(|e| Error::Custom(e.to_string()))?;
Ok(())
}
}
#[async_trait::async_trait]
impl Lookup for MintRow {
type Key = Pubkey;
async fn lookup(key: Pubkey, pool: &sqlx::PgPool) -> CarbonResult<Option<Self>> {
sqlx::query_as("SELECT * FROM mints WHERE pubkey = $1")
.bind(key)
.fetch_optional(pool)
.await
.map_err(|e| Error::Custom(e.to_string()))
}
}
#[async_trait::async_trait]
impl Delete for MintRow {
type Key = Pubkey;
async fn delete(key: Pubkey, pool: &sqlx::PgPool) -> CarbonResult<()> {
sqlx::query("DELETE FROM mints WHERE pubkey = $1")
.bind(key)
.execute(pool)
.await
.map_err(|e| Error::Custom(e.to_string()))?;
Ok(())
}
}
pub fn mint_processor(pool: sqlx::PgPool) -> PostgresAccountProcessor<TokenProgramAccount, MaybeMint> {
PostgresAccountProcessor::new(pool)
}Register it with .. It needs carbon-token-program-decoder = "2. and async-trait = "0. in addition to the dependencies above.