AstroML now has two complementary PostgreSQL schema layers:
- Raw Stellar ingestion tables used by the current stream pipeline.
- A normalized graph-mirror schema for account timelines and graph analytics.
The split is intentional. The raw layer preserves source fidelity, while the graph mirror provides stable surrogate keys, typed edges, and reviewer-friendly constraints for time-series retrieval.
The existing raw schema remains unchanged:
| Blockchain concept | Graph meaning | Table |
|---|---|---|
| Ledger close | Temporal anchor | ledgers |
| Transaction | Container for operations | transactions |
| Operation | Raw directed edge/event | operations |
| Account snapshot | Latest observed node state | accounts |
| Asset registry | Canonical asset dimension | assets |
These tables continue to support ingestion from Horizon and raw feature extraction.
The new graph mirror is optimized for normalized, account-centric reads:
| Mirror concept | Purpose | Table |
|---|---|---|
| Canonical node | One row per unique account | graph_accounts |
| Shared edge/event | Time-series fact table for transactions, claims, and payments | graph_edges |
| Transaction subtype | Transaction-only attributes | graph_transaction_details |
| Claim subtype | Claim-only attributes | graph_claim_details |
| Payment subtype | Payment-only attributes | graph_payment_details |
graph_edges.asset_id reuses the existing assets table so asset identity is
not duplicated across the raw and mirror layers.
erDiagram
assets ||--o{ graph_edges : categorizes
graph_accounts ||--o{ graph_edges : source
graph_accounts ||--o{ graph_edges : destination
graph_edges ||--o| graph_transaction_details : specializes
graph_edges ||--o| graph_claim_details : specializes
graph_edges ||--o| graph_payment_details : specializes
graph_accounts {
bigint id PK
varchar account_address UK
varchar account_type
timestamptz first_seen_at
timestamptz last_seen_at
timestamptz created_at
timestamptz updated_at
}
graph_edges {
bigint id PK
varchar edge_type
bigint source_account_id FK
bigint destination_account_id FK
int asset_id FK
timestamptz occurred_at
int ledger_sequence
int event_index
varchar transaction_hash
varchar external_event_id
numeric amount
varchar status
timestamptz created_at
}
graph_transaction_details {
bigint edge_id PK,FK
varchar edge_type
boolean successful
smallint operation_count
bigint fee
varchar memo_type
text memo
jsonb details
}
graph_claim_details {
bigint edge_id PK,FK
varchar edge_type
varchar claim_reference
varchar claim_status
timestamptz expires_at
jsonb details
}
graph_payment_details {
bigint edge_id PK,FK
varchar edge_type
varchar payment_reference
varchar payment_status
numeric fee_amount
timestamptz settled_at
jsonb details
}
Canonical node table for the mirror.
idis a surrogate key for compact foreign keys and stable joins.account_addressis the natural key and is unique.first_seen_atandlast_seen_attrack observation windows for the account inside the mirror, not on-chain account creation semantics.account_typeis optional because some upstream sources may classify accounts while others may not.
Shared event table for graph traversal and timeline queries.
edge_typeis constrained totransaction,claim, orpayment.source_account_idis required;destination_account_idis nullable only to support edge types where the counterparty is not yet known or not applicable.occurred_atis the business time for ordering and time-window filtering.created_atis ingestion time for operational observability.external_event_idis unique peredge_typeand is the intended idempotency/upsert key.ledger_sequenceandevent_indexprovide deterministic tie-breaking for sources that expose ledger order.
The mirror keeps the shared edge table narrow and pushes subtype attributes into 1:1 detail tables.
graph_transaction_detailsstores fields such asfee,memo, andsuccessful.graph_claim_detailsstores claim lifecycle fields such asclaim_reference,claim_status, andexpires_at.graph_payment_detailsstores payment lifecycle fields such aspayment_reference,payment_status,fee_amount, andsettled_at.- Each detail table has a composite foreign key to
(graph_edges.id, graph_edges.edge_type)plus a check constraint on its fixed subtype. This prevents a payment detail row from attaching to a claim or transaction edge.
The optional details JSONB column in each subtype table is reserved for
source-specific attributes that do not justify new columns yet.
The graph mirror is tuned for the expected read paths:
ix_graph_edges_occurred_atSupports global time-range scans.ix_graph_edges_source_occurred_atSupports outbound activity for one account ordered by time.ix_graph_edges_destination_occurred_atSupports inbound activity for one account over a time window.ix_graph_edges_type_occurred_atSupports per-type timelines fortransaction,claim, orpayment.ix_graph_edges_asset_occurred_atSupports asset-filtered timelines and rollups.ix_graph_edges_status_occurred_atSupports state-aware filtering in a time window.ix_graph_edges_tx_hashSupports reverse lookup from a known transaction hash.ix_graph_edges_ledger_eventSupports deterministic replay and incremental ingestion windows.ix_graph_accounts_last_seen_atSupports recency-oriented node lookups.
PostgreSQL btree indexes can be scanned in reverse order, so the composite
indexes remain effective for ORDER BY occurred_at DESC queries without making
the migration noisier.
This design is meant to support queries such as:
- all activity for one account ordered by
occurred_at - inbound or outbound edges over a time window
- all claims, payments, or transactions in a time range
- latest activity for a specific account
- filtering by
asset_id,edge_type, orstatuswithin a window - incremental ingestion keyed by
(edge_type, external_event_id)and replay ordered by(ledger_sequence, event_index)
- Account identity is stored once in
graph_accounts. - Shared edge attributes live once in
graph_edges. - Type-specific attributes are isolated in dedicated detail tables instead of a single sparse table with many nullable columns.
- Asset identity is reused from the existing
assetsdimension.
This keeps the schema normalized while still practical for operational reads.
- Use
external_event_idtogether withedge_typeas the upsert key. - When the source does not expose a single immutable event ID, the ingestion layer should derive one deterministically, for example from transaction hash plus operation or event order.
occurred_atandcreated_atare intentionally separate and should not be conflated in application code.destination_account_idshould remain null only when the source truly lacks a destination relationship.
- The project remains Stellar-oriented, so account identifiers use the existing 56-character account-address convention.
assetsremains the canonical asset dimension for both raw and mirror data.- Claim and payment sources may vary, so subtype tables expose a few strongly typed lifecycle columns plus a JSONB extension field for source-specific data.
- Add monthly or quarterly partitioning on
graph_edges.occurred_atonce row counts justify it. - Consider materialized daily rollups for high-volume account analytics.
- If ingestion begins mirroring directly from the raw
operationstable, add a dedicated lineage column or foreign key back to the raw record that produced each graph edge.
alembic upgrade head
alembic downgrade base