id represents, how a write affects current records, and how historical reads find the required version.
L1, L2, and subtables
BlockDB has two table layers. L1 and L2 are storage abstraction layers, unrelated to blockchain scaling layers.- L1 tables provide fields, primary keys, indexes, and row reads and writes. Normal Tables use this layer directly.
- L2 tables add block or time semantics on top of L1. Block Event, Block State, and Time Tables are L2 tables: they translate each logical write into updates to a set of L1 tables.
demo.pool_reserves.eth uses these subtables:
A block bundle is a fixed range of 1,000 blocks, numbered in height order: bundle
N covers heights (N-1) × 1000 + 1 through N × 1000. Batch writes and analytics synchronization both work in these units.
Subtables vary by table type. A Block Event Table’s data table shares its ID and is labeled event. Time Tables have latest and archive but do not need block-progress tables. latest and event describe the data tables’ roles; they are not suffixes to append to a table ID.
Use the SDK interface for the table type for routine reads and writes, so BlockDB can maintain its subtables together. Writing directly to a subtable can leave business data inconsistent with history or progress. Subtables are primarily useful for querying data and inspecting processing status.
Normal Tables: one current record per ID
A Normal Table usesid as its primary key. Writing a new id inserts a record; writing an existing id updates that record. Previous values are not retained automatically.
For example, the id = 'usdc' record in demo.asset_notes can store a token symbol and a business description. After updating the description, reading that ID returns the current content. To track changes, choose a table type that retains history or design your own history records.
Block Event Tables: records of individual events
A Block Event Table associates each event withblock_height, block_id, and block_timestamp. The underlying records are distinguished by block height and event id together, so events within the same block must use different IDs.
For example, if an address receives several USDC transfers in a day, each transfer needs its own event ID. A transaction and log position can usually identify a transfer. Using only the recipient address would cause transfers to that address within the same block to conflict.
Event tables retain individual occurrences. An object’s current balance must be calculated from events or stored separately in a state table; it cannot be read directly from a single transfer record.
Block State Tables: current state and historical versions
A Block State Table’sid identifies the object being tracked. For pool reserves, use the pool address and write a new state for that ID when reserves change in another block.
When a block is written, BlockDB saves the object’s archive version and maintains latest. In a historical record, original_id points to the business ID, while the record itself has an independent id. A write updates latest only if its block height is at least as high as the existing latest version. Backfilling older blocks therefore does not overwrite newer state with older values.
The two read methods use different data paths:
StateTable(table_id).get(id)reads latest directly, returning the object’s latest stored state.StateTable(table_id, block_id).get(id)checks block coverage, then finds the latest archive version at or before the target block.
25939930, reads at subsequent processed blocks return those reserves until the next change. A historical read does not require the object to have changed in the exact target block, nor does the table need to store another copy for every unchanged block.
Block data and processing progress
Business records show what data a block produced. A block with no USDC transfers may have been checked and had no matching events, or it may not have been processed at all. BlockDB therefore tracks block coverage separately. For a single-block write, BlockDB performs these steps:- Resolve the height and timestamp from the block ID and validate the write range.
- Write event records or maintain the state table’s latest and archive.
- Update block bundle summaries and merge the height into the processed ranges.
block_height in its records.
Suppose the start height is 25939930, and blocks 25939930 and 25939932 have been processed, but 25939931 has not. The latest record may come from 25939932, while consensus height remains at 25939930. It can advance only after the gap is filled.
A state read without a block ID does not fall back to consensus height. An event read by ID defaults to the record at the highest block height for that ID. To compare objects or tables at the same block, explicitly pass a block ID and confirm that every table covers it continuously from its own start height. A block above consensus height is rejected. Reading latest directly does not prove that the intervening blocks are complete.
See Advanced features for setting the start height, checking consensus height, and inspecting asynchronous write jobs.
Time Tables: values over time
A Time Table organizes state by wall-clock time using the fixed fieldsid, time_at, and value. It suits numeric series such as prices and is not tied to one chain’s blocks.
Time Tables also maintain latest and archive separately. TimeTable(table_id).get(id) reads the latest value. When time_at is specified, the server first rounds the time up to the corresponding bucket boundary, then finds the most recent historical record at or before that boundary. It does not interpolate values.
Time buckets group nearby samples into the same interval. Each object has one value per bucket, and repeated writes to that bucket update the value. As data ages, bucket granularity changes from minutes to hours and days to reduce long-term storage. Older data is therefore useful for trends, but cannot reconstruct every price change within a bucket. Specifying a time to the second does not provide a second-by-second historical snapshot.
See Read and write data for examples for each table type.