Skip to content

Latest commit

 

History

History
548 lines (436 loc) · 60.1 KB

File metadata and controls

548 lines (436 loc) · 60.1 KB

Data Schema Technical Specification

Postgres tables that back the DEX.DO read-model and indexer. Source of truth is the migration set under /migrations; this document describes intent and field semantics. Schema changes ship as numbered migration files (NNNN_*.sql) and are applied by sqlx::migrate! at service startup (crates/infrastructure/src/database.rs).

Tables fall into five buckets:

Bucket Tables Owner
Reference data ref_tokens Seeded by migrations; read-only at runtime.
Indexer infrastructure raw_events, indexer_cursors Indexer ingestion path.
Read-model — discovery oracles, oracle_event_lists, oracle_events Indexer projectors + OracleEventList reconciler.
Read-model — markets markets, market_outcomes, live_orders, order_book_snapshots Indexer projectors + market reconciler.
Read-model — inference markets inference_markets, inference_orders, inference_trades Indexer projectors + inference reconciler.
Read-model — inference deals inference_deals, inference_ticks Inference SETTLEMENT projector (writer). Intended to back the forthcoming rewards service (reader).
Authentication and credentials accounts, api_keys Operator-provisioned; read on every signed request by the auth middleware.

Glossary

Read-model — Postgres tables prepared for API reads. They are derived from chain events and contract state so the API can answer requests without decoding the blockchain state on every call.

Projector — code that handles one decoded chain event and writes the corresponding read-model change. For example, the OrderBook.OrderPlaced event creates or refreshes a row in live_orders.

Reconciler — background indexer task that periodically reads contract state through getters and fills fields that events alone do not provide. The market reconciler reads PMP state (getDetails, getOrderBookAddress) and updates markets / market_outcomes. The OracleEventList reconciler reads _events from each EventList contract and fills missing event metadata in oracle_events, such as describe and trust_addr.

Reference data

ref_tokens

Static collateral-token catalogue. The indexer joins against it when a PMPDeployed event references a tokenType; the API surfaces precision and trading-rule constants per outcome through it.

Column Type Notes
token_type integer PK Numeric token type as the contract uses it (NACKL=1, SHELL=2, USDC=3).
token_code text UNIQUE User-facing asset code (USDC, etc.).
decimals integer On-chain decimal places.
min_notional numeric(78,0) Minimum order notional, in raw uint256 units of the token. Scaled to a decimal at API render time.
lot_size numeric(78,0) Minimum order quantity increment, raw units.
tick_size_bps numeric(78,0) Price tick in basis points (contracts use TICK_SIZE = 10).
price_precision integer Decimal places for the price field exposed to clients.
quantity_precision integer Decimal places for the quantity field exposed to clients.
enabled boolean Reserved — not read on the hot path today.
created_at / updated_at timestamptz Bookkeeping.

Seeded values: (1, NACKL, 9, ...), (2, SHELL, 9, ...), (3, USDC, 6, ...). Adding a new collateral token is a migration-time change.

Indexer infrastructure

raw_events

The append-only event log. Every in-scope message edge from either filtered GraphQL stream lands here, decoded or not, before any projector runs. The gateway first selects the DEX src_dapp_id or legacy RootPN source; the indexer then keeps only the indexed dst allow-list, which explicitly excludes every TokenContract.* route. See Server-side capture scope and Ingest scope. It is the recovery boundary for the read-model: reprojection replays decoded but unprojected rows here, and downstream tables can always be rebuilt from this one plus a clean schema.

Column Type Notes
id bigserial PK Insertion order. Not used for ordering — that's chain_order below.
msg_id text UNIQUE Chain-side message id. Prevents duplicate ingestion across overlapping page fetches.
chain_order text NOT NULL Global lex-sortable chain order from the GraphQL gateway's msg_chain_order. The strict-monotonic projection key — created_at_chain collides within one second and drifts across shards, so any reproject sweep that ordered on time could apply OrderFilled before its parent OrderPlaced. Required on every row; edges arriving without it are dropped at ingest.
created_at_chain timestamptz Chain block timestamp from the GraphQL created_at field. Kept for diagnostics/analytics only — not load-bearing for ordering. Nullable, preserved as-is.
src_address text Source contract address (the contract that emitted the event).
dst_address text Destination address from the message header.
event_type text "<ContractKind>.<EventName>", e.g. OrderBook.OrderPlaced. NULL when decoding failed or the body was not an event message.
body_json jsonb Raw message body JSON as ingested.
decoded jsonb ABI-decoded event payload. Filled at ingest time if decoding succeeds; reprojection reuses this — bodies are not re-decoded.
processed_at timestamptz Stamped by the projector when the row is Applied or Unknown. NULL = pending; covered by the reprojection sweep.
created_at timestamptz Indexer ingestion time (wall-clock).

Indices:

Index Purpose
raw_events_event_type_idx General event_type scans (debug, analytics).
raw_events_event_type_decoded_idx (partial, event_type IS NOT NULL) Same scope but optimised for decoded rows.
raw_events_created_at_chain_idx (desc) Time-window queries (analytics only).
raw_events_chain_order_idx Backs the projection loop's ORDER BY chain_order ASC.
raw_events_pending_chain_order_idx (partial: processed_at IS NULL AND event_type IS NOT NULL AND decoded IS NOT NULL) Backs the projection loop's keyset scan (crates/infrastructure/src/indexer_repo.rs::reproject_pending_from). Keyed on chain_order to match the loop's ORDER BY.
raw_events_pending_src_idx (partial: processed_at IS NULL AND event_type IS NOT NULL AND decoded IS NOT NULL) Indexed on src_address. Allows the inference reconciler's sweep catch-up gate to probe "are there any pending events for this book?" as an index probe on src_address = orderbook_address, rather than a full-table scan.
raw_events_unprocessed_src_idx (partial: processed_at IS NULL) Indexed on src_address. Backs the read gate's "we cannot see everything for this book" probe: every event an InferenceOrderBook emits is in the loaded ABI, so any unprojected row under that src_address — pending-decodable, undecoded, bodyless, or an unrecognized id — means the view of that book is incomplete. Broader than raw_events_pending_src_idx's decode-outcome-scoped predicate, so Postgres cannot reuse that index for this probe.

indexer_cursors

Resume-points and the synchronized projection barrier. The indexer persists each source cursor after every page so a restart does not reprocess the full retained history. When no row exists for a source, capture bootstraps from the oldest matching event the gateway still retains rather than from a null cursor — see Cold start.

The production rows are:

stream_name Role
blockchain_events_dex_dapp Resume cursor and at_head for blockchain.events(src_dapp_id = DEX_DAPP_ID).
blockchain_events_root_pn Resume cursor and at_head for RootPN.account.events.
blockchain_events Aggregate ordering/freshness row. Its cursor is the largest globally ordered prefix proved complete by both source streams; its at_head is true only when both streams reached head in one successful tick. The projector, metrics, inference orphan gate, and read API consume this row.
Column Type Notes
stream_name text PK Logical stream identifier (e.g. one per filter-set the indexer subscribes to).
cursor text Source rows: opaque msg_chain_order cursor returned by GraphQL. Aggregate row: synchronized projection watermark computed from the source cursors.
updated_at timestamptz Source rows: last successful page commit. Aggregate row: last tick in which both source drains completed successfully.
at_head boolean NOT NULL default false Source rows mirror that stream's has_next_page=false. Aggregate row is true only when both source streams are at head; the inference reconciler and read API use that aggregate state as their catch-up gate.

Read-model — discovery

The discovery side of the indexer tracks oracles, their event lists, and the events those lists carry. These tables feed the event.* block in /api/v1/prediction/markets responses.

oracles

One row per oracle service the system knows about. Populated by the RootOracle.OracleDeployed event and back-filled from EventList parent lookups.

Column Type Notes
id bigserial PK Internal FK target.
name text UNIQUE Oracle name as registered on chain (e.g. ElectionOracle).
address text UNIQUE Oracle contract address.
deploy_msg_id text UNIQUE (nullable) Message id of the deploy event. NULL if the oracle was discovered indirectly.
pubkey text Oracle pubkey from the deploy event.
created_at / updated_at timestamptz Bookkeeping.

oracle_event_lists

Each oracle owns a sequence of EventList contracts created by the Oracle.OracleEventListDeployed event. The indexer's OracleEventList reconciler processes one EventList at a time: it reads that contract's _events getter and updates the related oracle_events rows with metadata such as describe and trust_addr.

Column Type Notes
id bigserial PK Internal FK target.
msg_id text UNIQUE Deploy event message id.
oracle_id bigint FK → oracles(id) ON DELETE CASCADE Parent oracle.
address text UNIQUE EventList contract address.
list_index bigint Oracle-local index of the event list.
description text NOT NULL Human-readable list description from the OracleEventListDeployed event (always carried; projector reads it strictly). May be an empty string, never null. Surfaced as /api/v1/oracles eventLists[].description.
created_at timestamptz Bookkeeping.
last_reconcile_failed_at timestamptz Stamped when a reconcile attempt fails. Used for backoff and queue ordering.
reconcile_attempts integer default 0 Diagnostic counter for permanently broken EventLists.

Index: oracle_event_lists_oracle_id_idx speeds up loading all EventList rows for one oracle.

oracle_events

The actual events inside each EventList. Two writers:

  • Projector writes event_name, oracle_fee, deadline, and the confirmed_* columns from the EventAdded and EventConfirmed events, and — for numeric range eventsrange_ob_address + range_bounds_jsonb from the RangeEventAdded event (which carries both).
  • OracleEventList reconciler fills the metadata that lives only in getter state: describe and trust_addr from _events.
Column Type Notes
id bigserial PK Internal FK target.
eventlist_id bigint FK → oracle_event_lists(id) ON DELETE CASCADE Parent EventList.
internal_id_in_eventlist numeric(78,0) Event id within the EventList. The pair (eventlist_id, internal_id_in_eventlist) is UNIQUE.
event_name text From the EventAdded event. Surfaces as event.eventName.
oracle_fee numeric(78,0) From the EventAdded event.
deadline bigint Event deadline (unix seconds).
describe text Event description — reconciler-only field. NULL until OracleEventList reconciler runs.
count numeric(78,0) Reserved metadata field from _events.
trust_addr text Reconciler-only field. Optional on chain — may stay NULL even after reconciliation.
outcome_names_jsonb jsonb default '{}'::jsonb Outcome label map (outcomeId → name).
range_ob_address text (nullable) For a numeric range event: the InferenceOrderBook whose weekly-median price resolves the outcome (OracleEventList._rangeData[eventId].ob, spec §6.2). NULL for plain events. Set by the RangeEventAdded projector. Indexed by oracle_events_range_ob_idx; the reverse lookup (markets resolving from a given inference book) backs the resolvesFrom filter on /api/v1/prediction/markets.
range_bounds_jsonb jsonb (nullable) For a range event: the strictly-increasing numeric upper bounds (n bounds → n+1 outcomes), as a JSON array of decimal strings. NULL for plain events. Set by the RangeEventAdded projector. The human labels for those ranges are already in outcome_names_jsonb, so the API does not re-expose the raw bounds.
is_deleted boolean default false Soft-delete flag for events that disappear from the EventList.
last_seen_at timestamptz Updated on every projector pass that touches the row.
confirmed_pmp_address text Set by the EventConfirmed event. Links an event to the PMP that markets it.
confirmed_at timestamptz Stamp time recorded when the projector observes the confirmation event.
meta_reconciled_at timestamptz Per-row marker — set unconditionally by the OracleEventList reconciler after a successful getter pass, even when describe/trust_addr come back NULL on chain. Drives the pending-row predicate so legitimately-null fields don't cause infinite re-fetch.
created_at / updated_at timestamptz Bookkeeping.

Indices:

Index Purpose
oracle_events_eventlist_id_idx Speeds up loading all event rows for one EventList.
oracle_events_deadline_idx Time-window queries.
oracle_events_confirmed_pmp_idx (partial: confirmed_pmp_address IS NOT NULL) Reverse-lookup from PMP back to event.
oracle_events_pending_meta_idx (partial: meta_reconciled_at IS NULL) Drives the OracleEventList reconciler's pending-row SELECT.
oracle_events_range_ob_idx (partial: range_ob_address IS NOT NULL) Reverse lookup from an inference order book to the range events (and thus prediction markets) that resolve from it. Backs the resolvesFrom filter on /api/v1/prediction/markets.

Read-model — markets

markets

One row per PMP (Prediction Market Pool) contract observed on chain. Discovered by the PMPDeployed event, completed by the market reconciler reading PMP.getDetails(), and transitioned by the TimingsSet event, the PoolsFrozen event, the Resolved event, the PMPRejected event, and the EventCancelled event. Hidden from the public API until last_reconciled_at is non-null.

Column Type Notes
id bigserial PK Internal FK target.
pmp_address text UNIQUE The PMP contract address. Exposed as predictionMarketAddress.
market_id text Market identifier from getDetails(). NULL pre-reconcile.
name text Market display name from getDetails(). Surfaces as marketName.
token_type integer FK → ref_tokens(token_type) Quote-asset token type.
token_code text Quote-asset code (denormalised from ref_tokens for read speed).
event_id numeric(78,0) Oracle event id this market resolves against.
oracle_list_hash numeric(78,0) EventList hash used in OrderBook derivation. NULL pre-reconcile.
orderbook_address text The deterministic OrderBook address returned by PMP.getOrderBookAddress(). Written by the market reconciler on the first successful pass, including pre-PoolsFrozen rows. Nullable only during the pre-reconcile window; the CHECK predicate last_reconciled_at IS NULL OR orderbook_address IS NOT NULL enforces that every market visible to the API has a non-null predictionOrderBookAddress. A partial UNIQUE index on orderbook_address WHERE orderbook_address IS NOT NULL pins the contract-side per-market invariant — /api/v1/prediction/orders joins live_orders to markets on this column and relies on the at-most-one-row guarantee.
approved boolean default false Approval flag from getDetails(); flipped to true by the TimingsSet event.
is_cancelled boolean default false On-chain cancellation flag from getDetails(). Either this or cancelled_at being set is enough to flip the derived status to CANCELLED.
stake_start / stake_end / result_start / result_end bigint (nullable) Lifecycle timings (unix seconds). Written only by the TimingsSet event; reconciler does not touch these (H2 fix). NULL on all four = PENDING.
num_outcomes integer default 0 Outcome count from getDetails().
oracle_event_lists_json jsonb Auxiliary data from the PMPDeployed event for outcome-resolution.
oracle_fee_json jsonb Same.
last_reconciled_at timestamptz Stamped by the market reconciler after a successful pass. The public API filters on last_reconciled_at IS NOT NULL — markets without this are invisible to clients.
frozen_at bigint Block timestamp of the PoolsFrozen event. Required for any post-freeze status (TRADING / RESOLVING / EXPIRED / RESOLVED).
resolved_at bigint Block timestamp of the PMP.Resolved event.
resolved_outcome_id integer Winning outcome id.
cancelled_at bigint Block timestamp of the PMP.PMPRejected or PMP.EventCancelled event. May also be back-filled to now() by the reconciler if the chain flag flipped before the event was replayed.
cancel_reason text 'PMP_REJECTED_BY_ORACLE' or 'EVENT_CANCELLED'. Required when cancelled_at is set; the API fails closed (HTTP 503) when CANCELLED is derived without a valid reason.
last_reconcile_failed_at timestamptz Backoff bookkeeping for the market reconciler.
reconcile_attempts integer default 0 Diagnostic counter.
created_at / updated_at timestamptz Bookkeeping.

Indices:

Index Purpose
markets_market_id_idx Lookup by market_id.
markets_status_idx (approved, is_cancelled) Coarse status filters.
markets_pending_reconcile_idx (partial: last_reconciled_at IS NULL) Drives the market reconciler's pending-row SELECT.
markets_terminal_idx (partial: resolved_at IS NOT NULL OR cancelled_at IS NOT NULL) Terminal-status filters.
markets_orderbook_address_unique (partial UNIQUE: orderbook_address IS NOT NULL) Pins the per-market invariant; relied on by /api/v1/prediction/orders's all-markets join.

market_outcomes

One row per outcome of each market. Source for outcome listings and the per-outcome trading-rule constants the API publishes. Populated by the reconciler after getDetails() resolves outcome names + per-outcome precision metadata.

Column Type Notes
id bigserial PK Internal FK target.
market_id_fk bigint FK → markets(id) ON DELETE CASCADE Parent market.
pmp_address text Denormalised from markets.pmp_address for fast (pmp_address, outcome_id) joins.
outcome_id integer Stable outcome id used in trading. The pair (pmp_address, outcome_id) is UNIQUE.
outcome_name text Outcome display name.
symbol text UNIQUE The outcome-token symbol (<marketName>-<OUTCOME_NAME>).
price_precision integer Decimal places for prices. Used at API render time to scale raw uint256 prices.
quantity_precision integer Decimal places for quantities. Same.
tick_size text Minimum price increment as a decimal string.
step_size text Minimum quantity increment as a decimal string.
min_notional text Minimum order notional as a decimal string.
created_at / updated_at timestamptz Bookkeeping.

Index: market_outcomes_market_id_fk_idx speeds up loading all outcome rows for one market. Symbol is globally unique by construction.

live_orders

Per-order read model backing /api/v1/prediction/depth and account-scoped GET /api/v1/prediction/orders. One row per chain-side order, mutated in place as OrderPlaced, OrderFilled, and OrderCancelled events arrive. Rows are never deleted — FILLED / CANCELLED entries remain so that history queries and the depth handler (max(last_chain_order) across all rows for the (orderbook, outcome) pair) both see them.

Column Type Notes
orderbook_address text (PK part 1) OrderBook contract address.
order_id numeric(78,0) (PK part 2) Chain-side order id. The pair (orderbook_address, order_id) is the primary key.
outcome_id integer Which outcome this order is on.
is_buy boolean Side. true = bid, false = ask.
price numeric(78,0) Order price as the contract emitted it — raw uint256 in basis points (probability × FULL_PERCENT = 10 000). Decoded to a decimal at API render time (÷ FULL_PERCENT, formatted at price_precision).
amount_initial numeric(78,0) Original order quantity from OrderBook.OrderPlaced, in raw token atoms10^decimals). Used with amount_remaining to render origQty / executedQty (decoded ÷ 10^decimals, formatted at quantity_precision) in account order endpoints.
amount_remaining numeric(78,0) Quantity not yet filled. Set by the OrderPlaced event and decremented by the OrderFilled event. OrderCancelled preserves the current value as the cancelled remainder so /api/v1/prediction/orders.executedQty can be derived as amount_initial - amount_remaining; depth ignores the row because status != 'OPEN'. See the orders cancel-remainder cutover note for data-bearing deployment guidance.
client_order_id text Optional client-supplied id.
owner_pn_address text Trading PrivateNote address that owns the order. Initially NULL from OrderBook.OrderPlaced; attached by PrivateNote.OrderPlacedConfirmed using the event source address. NULL rows can still contribute to public depth, but cannot appear in account-scoped order responses.
status text CHECK IN ('OPEN', 'FILLED', 'CANCELLED') Order lifecycle. Depth aggregation filters on status = 'OPEN' AND amount_remaining > 0. The CHECK is extended to include 'REJECTED' by the contracts-side follow-up documented in read-api.md §REJECTED — future work; until then no row carries that value.
last_chain_order text NOT NULL Chain-order key (msg_chain_order from the gateway) of the most recent OrderBook event that touched this order. Lex-monotonic via greatest(existing, new) on OrderBook writes. Feeds lastUpdateId in depth responses as a STRING.
chain_created_at timestamptz On-chain block time of the originating OrderBook.OrderPlaced. Drives time in /api/v1/prediction/orders. Display-only. NULL for pre-migration rows.
chain_updated_at timestamptz On-chain block time of the most recent order book event that affected the order — OrderPlaced, OrderFilled, OrderCancelled. Advanced via greatest(...). Drives updateTime in /api/v1/prediction/orders. Display-only. NULL for pre-migration rows.
placed_chain_order text not null msg_chain_order of the OrderPlaced event that created the row. First-write-wins (coalesce on conflict). Sole sort key + cursor for /api/v1/prediction/orders (DESC).
created_at / updated_at timestamptz Bookkeeping.

Index: live_orders_open_book_idx — partial index on (orderbook_address, outcome_id, is_buy, price DESC) with predicate status = 'OPEN'. Sized for the depth query: top-N price levels per side and outcome.

Index: live_orders_owner_idx — partial index on (owner_pn_address, placed_chain_order DESC) with predicate owner_pn_address IS NOT NULL AND chain_created_at IS NOT NULL.

Serves as the seek path for the cursor-based /api/v1/prediction/orders query (DESC by chain-order): a single- column lexicographic range scan over placed_chain_order. The partial predicate confines the index to owner-attributed rows whose timestamps are renderable. Status filtering (OPEN vs FILLED vs CANCELLED vs the future REJECTED) is intentionally a heap-side predicate so that one index covers both the default "all statuses" query and any CSV-driven subset; per-owner row counts are small enough that this is cheaper than maintaining a wider composite index.

The chain_updated_at IS NOT NULL condition stays in the SQL query as a heap filter, keeping the index independent of a display-only timestamp column that advances on every OrderFilled event.

Cancel projection preserves amount_remaining, so executedQty = amount_initial - amount_remaining holds across cancellation. Data-bearing cutover guidance lives in orders-cancel-remainder-cutover.md.

trades

Append-only public trade tape backing GET /api/v1/prediction/trades. One row per maker↔taker match, written by the OrderBook.OrderFilled projector on the taker-side event only (isTaker = true) — the maker-side event mutates live_orders but writes no trades row, so a match is recorded exactly once. Rows are immutable once written, except a first-write-wins fill of a NULL chain_time on replay; never deleted. Write-side derivation in indexer.md; read side in read-api.md.

Column Type Notes
trade_id text PK The taker-side OrderFilled event's chain-order key (msg_chain_order from the gateway, copied from raw_events.chain_order). Globally unique per match and lex-sortable — the sole sort key and identity for /api/v1/prediction/trades (DESC). The identical value is specified to surface as orderUpdate's t field for the same fill (api-spec.md).
orderbook_address text NOT NULL OrderBook contract address. With outcome_id, scopes the tape to one market outcome.
outcome_id integer NOT NULL Which outcome the match is on.
price numeric(78,0) NOT NULL Match (clearing) price from OrderFilled.clearingPrice — raw uint256 basis points (probability × FULL_PERCENT = 10 000). Decoded ÷ FULL_PERCENT, formatted at price_precision, at API render.
qty numeric(78,0) NOT NULL Matched quantity from OrderFilled.filledAmount — raw token atoms10^decimals). Decoded ÷ 10^decimals, formatted at quantity_precision. quoteQty is derived at render as price * qty / FULL_PERCENT (the contract's integer-division notional), not stored.
is_buyer_maker boolean NOT NULL Trade direction, derived from the taker order's side: taker selling ⇒ the buyer is the maker ⇒ true; taker buying ⇒ false. Surfaces as isBuyerMaker.
chain_time timestamptz On-chain block time of the taker OrderFilled event (raw_events.created_at_chain). Drives time (Unix ms) in trade responses. NULL when the gateway omitted created_at; such rows are filtered out of the read query, matching live_orders / /api/v1/prediction/orders.
created_at timestamptz Bookkeeping (indexer ingestion wall-clock).

Index: trades_tape_idx(orderbook_address, outcome_id, trade_id DESC). Backs the newest-first per-outcome read (ORDER BY trade_id DESC LIMIT $limit) as an index range scan. trades is insert-only; a replayed insert conflicts on trade_id and only coalesces a NULL chain_time (first-write-wins), so reprojection from raw_events is idempotent.

Recovery notes for on-call:

  • Row hidden by chain_time IS NULL (gateway delivered the fill without created_at): fix the trades row directly — UPDATE trades SET chain_time = to_timestamp(...) WHERE trade_id = ..., sourcing the timestamp from the repaired raw_events.created_at_chain. Do not clear the event's processed_at while its order is still live: replay re-runs the whole OrderFilled projection, and the live_orders fill arm is not replay-idempotent (filledAmount would be subtracted again — see reproject_pending's doc). A replay-based heal via the conflict-arm coalesce is safe only when the order row is already terminal, or during a wholesale reprojection that rebuilds live_orders from scratch.
  • Tape 503s for one outcome (MarketInconsistent from an undecodable price/qty): the table is append-only and the read is newest-first, so one corrupt row inside the newest limit rows fails every request for that (orderbook_address, outcome_id) until it is fixed. The failing trade_id/axis/raw value is logged by the read path; verify against the originating raw_events row, then correct or delete the corrupt trades row manually.

order_book_snapshots

Reserved table for cached depth snapshots. Not used by the current depth handler — /api/v1/prediction/depth aggregates live_orders on every request. Kept in the schema for a future cache-warming path; safe to ignore until then.

Column Type Notes
id bigserial PK
symbol text UNIQUE Outcome symbol.
orderbook_address text
last_update_id bigint
bids_jsonb / asks_jsonb jsonb default '[]'::jsonb
updated_at timestamptz

Read-model — inference markets

The inference side tracks the per-model order books of the private-inference market (contracts/airegistry/InferenceOrderBook.sol — one book per model) and the resting orders inside them. These tables back /api/v1/inference/markets (list, plus single-market via ?inferenceOrderBookAddress=) and /api/v1/inference/depth (order book). Inference-settled prediction markets add no table of their own — /api/v1/prediction/markets?resolvesFrom= reuses markets joined to the range-event columns on oracle_events (range_ob_address). As on the prediction-market side, a row is hidden from the public API until the inference reconciler stamps last_reconciled_at.

inference_markets

One row per InferenceOrderBook contract observed on chain — equivalently, one tradable model. The book's address is derived from the model identity alone (DexLib.computeInferenceOrderBookAddress(code, modelHash) — one book per _modelHash, the tick-size component was dropped), so the address and model_hash are 1:1. Discovered from the first order event on an unknown book address (see indexer.md), completed by the inference reconciler reading InferenceOrderBook.getParams() and getWeeklyMedianPrice().

Column Type Notes
id bigserial PK Internal FK target.
orderbook_address text UNIQUE The InferenceOrderBook contract address. Exposed as inferenceOrderBookAddress — the public market id.
model_hash numeric(78,0) UNIQUE On-chain model identity (_modelHash static), from getParams(). The only model identifier the order book itself carries. NULL only during the pre-reconcile window; the visibility gate guarantees it is set on every market the API returns.
model_ref text (nullable) Human-readable model id producer--model--version. The order book carries only the hash; the name comes from the book's getModelName() getter (the ManifestMetadata contract that earlier held the manifest was removed upstream in v4.0.10). Filled by the inference reconciler on the discovery pass (fill_params); NULL when the getter returns an empty name, and the API then surfaces the model by hash alone.
producer / model_name text (nullable) Parsed components of model_ref (the producer and model parts of producer--model--version), for the model.{producer,name} render. Filled together with model_ref from getModelName(); both NULL unless the name is a clean three-part producer--model--version.
version text (nullable) Contract version reported by the book's getVersion() getter (e.g. 4.0.14), captured by the inference reconciler. Used by the model-slot supersede resolution (parsed as semver to pick the canonical book on a cross-version redeploy) — NOT the model version (that is model_version). Surfaced verbatim by the read API as contractVersion on /api/v1/inference/markets and /api/v1/inference/depth (see read-api.md).
model_version text (nullable) Parsed model version — the version part of a producer--model--version model_name. Renders as the API's model.version. Filled with the other identity columns from getModelName(); NULL unless the name is a clean three-part value.
manifest_address text (nullable) 🚧 Vestigial. Was the model's ManifestMetadata contract address (the reconcile source for model_ref); that contract was removed upstream in v4.0.10, so the column stays NULL and the name is sourced from InferenceOrderBookDeployed / getModelName instead.
root_model_address text (nullable) 🚧 The model's RootModel address. Diagnostic / reconcile aid. NULL until linked.
owner_pubkey numeric(78,0) (nullable) 🚧 Model-owner pubkey (RootModel.getOwnerPubkey()). NULL until resolved. Note: buyerPubkey is present on-chain but is intentionally not stored — there is no per-order ownership column on inference_orders.
platform_fee_bps integer Platform fee in basis points (getParams().platformFeeBps, e.g. 250). Filled by the inference reconciler on the first discovery pass from InferenceOrderBook.getParams(). Renders the buyer-side takerCommission (÷ 10 000 → "0.025"). The seller-side makerCommission is the rebate cap −REBATE_MAX_BPS (−0.02), a protocol constant; like /api/v1/prediction/markets, commissions are rendered (not stored per-row).
quote_token_type integer FK → ref_tokens(token_type) Quote asset of the book. Reconciler sets this to SHELL (token_type = 2) as a constant on the discovery pass — it is not sourced from a getter field.
price_precision integer Decimal places for price-per-tick at API render. Reconciler sets this to the constant 9 (SHELL decimals) on the discovery pass.
quantity_precision integer Decimal places for tick quantity. Reconciler sets this to the constant 0 (ticks are integer units) on the discovery pass.
tick_size text Minimum price-per-tick increment as a decimal string. Reconciler sets this to the constant "0.000000001" (1 SHELL atom) on the discovery pass.
step_size text Minimum tick-quantity increment as a decimal string. Reconciler sets this to the constant "1" on the discovery pass.
min_notional text Minimum order notional as a decimal string. Reconciler sets this to the constant "0.000000001" on the discovery pass.
reference_price numeric(78,0) (nullable) Weekly-median reference price in SHELL atoms (getWeeklyMedianPrice()). Reconciler-filled on the discovery pass and re-fetched on the reference-price refresh cadence. NULL when the book is dry — the getter reverts ERR_NO_LIQUIDITY on insufficient volume, the reconciler records NULL, and the API surfaces referencePrice: null.
reference_price_at timestamptz (nullable) When reference_price was last refreshed.
created_at_chain timestamptz (nullable) On-chain block time the book was first observed (from the seed event's created_at_chain). Drives createdAt.
last_reconciled_at timestamptz Stamped by the inference reconciler after a successful discovery pass. The public API filters on last_reconciled_at IS NOT NULL — books without this are invisible to clients (mirrors markets).
last_reconcile_failed_at timestamptz Backoff bookkeeping for the inference reconciler.
reconcile_attempts integer default 0 Diagnostic counter.
superseded_at timestamptz (nullable) Set when this book is retired as a stale duplicate of a higher-version book for the same model (cross-version contract redeploy). NULL = active. Non-NULL = retired; excluded from discovery, the read API, and the discovering/visible/failing metric buckets.
last_swept_at timestamptz (nullable) Stamped now() on every sweep batch tick that passes the catch-up gates — not only on cycle completion. It drives the Queue B sweep cadence (now() - last_swept_at >= inference_sweep_interval_ms), so it must advance each tick. NULL until the first sweep. Used with inference_markets_sweep_idx to drive Queue B sweep scheduling.
sweep_cursor numeric(78,0) (nullable) The order_id cursor for the current bounded round-robin sweep of OPEN orders. NULL at cycle start (or after a reset). The sweep resumes from this cursor on the next tick; NULL means start from the lowest order_id.
sweep_cycle_max numeric(78,0) (nullable) Snapshot of the highest order_id at sweep-cycle start. Newly-minted orders above this bound are deferred to the next cycle — they cannot be phantoms yet when the book just accepted them. NULL when no cycle is in progress.
sweep_override_seq bigint NOT NULL default 0 Monotonic counter bumped whenever a InferenceFilled event overrides a provisionally sweep-cancelled order while the book is still in the discovery phase. The discovery visibility stamp (transition to last_reconciled_at IS NOT NULL) is CAS-guarded on this counter being unchanged across the completing sweep tick, preventing a premature stamp when an override reset sweep_cursor to NULL at the start of a cycle where a plain cursor-CAS cannot distinguish reset-from-NULL from a normal start-of-cycle-NULL.
created_at / updated_at timestamptz Bookkeeping.

Indices:

Index Purpose
inference_markets_model_hash_idx (partial UNIQUE: model_hash IS NOT NULL) Lookup / dedup by on-chain model identity. Partial to tolerate NULL during the pre-reconcile window.
inference_markets_pending_reconcile_idx (partial: last_reconciled_at IS NULL) Drives the inference reconciler Queue A (discovery) SELECT, ordered by last_reconcile_failed_at NULLS FIRST, id.
inference_markets_refresh_idx (partial: last_reconciled_at IS NOT NULL) Drives the inference reconciler Queue B (reference-price refresh) SELECT, ordered by reference_price_at NULLS FIRST.
inference_markets_sweep_idx (partial: last_reconciled_at IS NOT NULL) Drives the inference reconciler Queue B sweep scheduling, ordered by last_swept_at NULLS FIRST.

reference_price re-queues independently of the discovery reconcile: it moves with trading, so the reconciler refreshes it on a cadence (it is not a write-once field like markets.orderbook_address). See indexer.md.

inference_orders

Per-order read model backing /api/v1/inference/depth (order-book depth) and /api/v1/inference/orders (per-order listing, public and unauthenticated). One row per chain-side order on an InferenceOrderBook, mutated in place as InferenceOrderPlaced, InferenceFilled, InferenceOrderCancelled, InferenceRefunded, and InferenceSubscriptionPlaced events arrive (the Inference prefix is part of the on-wire event name since v4.0.10). Mirrors live_orders: rows are never deleted, so FILLED / CANCELLED entries remain and the depth handler (max(last_chain_order) across all rows for the book) still sees them. This version exposes no account-scoped (owner-authenticated) inference order endpoint, so no ownership / private-read columns are required.

Column Type Notes
orderbook_address text (PK part 1) InferenceOrderBook contract address.
order_id numeric(78,0) (PK part 2) Chain-side order id (InferenceOrderPlaced.orderId). The pair (orderbook_address, order_id) is the primary key.
is_buy boolean Side. true = bid (buy order / subscription), false = ask (sell offer).
price numeric(78,0) Price per tick P in SHELL atoms, as emitted (InferenceOrderPlaced.price). BUY = max price per tick; SELL = offer price. Pure taker orders (IOC / FOK / MARKET) never rest, so they produce no OPEN row. Decoded ÷ 10^9 at render, formatted at price_precision.
amount_initial numeric(78,0) Original tick count (InferenceOrderPlaced.ticks).
amount_remaining numeric(78,0) Ticks not yet filled. Set by InferenceOrderPlaced, decremented by InferenceFilled. InferenceOrderCancelled preserves the current value; depth ignores the row because status != 'OPEN'.
is_subscription boolean default false true when the resting buy came from InferenceSubscriptionPlaced (spec §8 — a standing bid throttled by a weekly budget). Rests in the book like any other bid; flagged for diagnostics.
status text CHECK IN ('OPEN','FILLED','CANCELLED','EXPIRED') Order lifecycle. Depth aggregation filters on status = 'OPEN' AND amount_remaining > 0. A SELL offer is a one-deal slot consumed on match; a BUY maker reduces across fills. EXPIRED is written only by InferenceOrderExpired, when the book drops an order whose deadline passed — a row whose deadline already lies in the past stays OPEN until that event lands, so nothing derives the status from wall-clock. It overrides a provisional sweep-cancel (swept_at NOT NULL, which the sweep set precisely because the order had vanished from the book) but never a FILLED row or a real event-cancel.
swept_at timestamptz (nullable) Stamped by the reconciler sweep when getOrder() confirms the order is no longer in the book and the row is provisionally cancelled. NULL while the order has not yet been swept.
note_address text (nullable) Owner note address (InferenceOrderPlaced.note). Not on the public hot path; kept for diagnostics and cancel attribution.
token_contract text (nullable) Deal TokenContract. Two writers. (1) The InferenceOrderPlaced handler decodes the mandatory tokenContract field strictly (a missing field fails the projection rather than inserting a NULL) and normalizes a decoded zero address to NULL — on chain a BUY carries the zero address there, so token_contract is non-NULL only on SELL rows. A live SELL with a NULL value means the indexer does not know it yet, and the read API refuses TokenContract lookups until the reconciler fills it in. (2) The inference reconciler's phantom-cancel sweep (repair_order_fields) fills it from the tokenContract the getOrder probe already returns, but only while the column is still NULL and the getter supplies a non-zero address — repairing token_contract and deadline independently, so a BUY's zero address is left NULL. See Inference reconciler. The upsert's conflict arm is NULL-preserving (coalesce(excluded.token_contract, inference_orders.token_contract)), so a replay of InferenceOrderPlaced or of InferenceSubscriptionPlaced (which carries no tokenContract at all) can never erase a value the reconciler recovered from chain.
deadline numeric(20,0) (nullable) Unix seconds. Two writers. (1) The InferenceOrderPlaced handler decodes the mandatory deadline field strictly and normalizes a decoded 0 to NULL — a resting SELL carries deadline 0 on chain. NULL also when not yet known: InferenceSubscriptionPlaced omits the deadline the chain stores (the handler passes NULL for it), so a subscription row starts with a NULL deadline. (2) The inference reconciler's phantom-cancel sweep (repair_order_fields) fills it from the deadline the getOrder probe already returns, but only while the column is still NULL and the getter supplies a non-zero value — independently of token_contract, so a resting SELL's zero deadline is left NULL. See Inference reconciler. Same NULL-preserving upsert as token_contract, so no replay can erase a reconciler-recovered value.
last_chain_order text NOT NULL Chain-order key of the most recent book event that touched this order. Lex-monotonic via greatest(existing, new). Feeds lastUpdateId in depth responses as a STRING.
chain_created_at / chain_updated_at timestamptz On-chain block times of the originating InferenceOrderPlaced and the most recent touch. Display / diagnostic only.
created_at / updated_at timestamptz Bookkeeping.

Indices:

Index Purpose
inference_orders_open_book_idx (partial: status = 'OPEN') (orderbook_address, is_buy, price DESC). Sized for the depth query: top-N price levels per side for one book.
inference_orders_sweep_idx (partial: status = 'OPEN') (orderbook_address, order_id). Backs the reconciler's bounded round-robin sweep SELECT over OPEN rows, keyed by book + cursor position.
inference_orders_book_tc_idx (partial: token_contract IS NOT NULL) (orderbook_address, token_contract, status, order_id DESC). Backs the tokenContract lookup. status precedes the keyset because one TC's row count is unbounded — a seller may re-place the same TC after a cancel, and rows are never deleted, so a status residual would walk that TC's whole history.
inference_orders_book_note_idx (partial: note_address IS NOT NULL) (orderbook_address, note_address, is_buy, status, order_id DESC). Backs the note filter alone or combined with side/status; every leading column is pinned per query branch, so the keyset order survives.
inference_orders_live_sell_tc_null_idx (partial: status = 'OPEN' AND is_buy = false AND token_contract IS NULL) (orderbook_address). Backs the read path's fail-closed probe for a resting SELL whose TokenContract is unknown. Empty whenever every live SELL has one, so the EXISTS is O(1).
inference_orders_book_side_status_idx (orderbook_address, is_buy, status, order_id DESC). Serves side-only, status-only, side+status and unfiltered listings: the query plan pins is_buy and status in every branch, so one composite covers all four shapes.
inference_orders_book_chain_order_idx (orderbook_address, last_chain_order DESC). max(last_chain_order) per book is the lastUpdateId watermark for the depth and the orders endpoints; rows are never deleted, so without this index it scans a book's whole history on every request.

inference_trades

Append-only public trade tape backing GET /api/v1/inference/trades (see read-api.md). One row per maker↔taker match, written by the InferenceOrderBook.InferenceFilled projector. Unlike trades on the prediction side there is no taker-side gate: the inference book emits one InferenceFilled per match (carrying both makerId and takerId), so the event itself is already one-per-match — every InferenceFilled that both legs resolve for writes exactly one row. There is also no outcome dimension: an InferenceOrderBook is one book per model, so the tape is keyed by orderbook_address alone. Write-side derivation in indexer.md.

Column Type Notes
trade_id text PK The InferenceFilled event's chain-order key (msg_chain_order from the gateway, copied from raw_events.chain_order). Globally unique per match and lex-sortable — the sole sort key and identity for the tape (DESC).
orderbook_address text NOT NULL InferenceOrderBook contract address. No outcome column — one book per model.
price numeric(78,0) NOT NULL Match (clearing) price from InferenceFilled.clearingPrice — raw quote-asset (SHELL) base units per tick. Unlike the prediction tape's price this is not basis points.
qty numeric(78,0) NOT NULL Matched tick count from InferenceFilled.ticks, raw.
is_buyer_maker boolean NOT NULL Trade direction. Not carried by the eventInferenceFilled has no isBuyerMaker field. Resolved at projection time from the MAKER leg's is_buy in inference_orders (the resting side locked by makerId); when that leg is absent (orphan repair with a missing counterparty), the inverse of the taker leg's is_buy is used instead — a match has exactly one buyer and one seller.
chain_time timestamptz On-chain block time of the InferenceFilled event (raw_events.created_at_chain). NULL when the gateway omitted created_at; such rows are filtered out of the read query, matching inference_orders / the prediction tape.
created_at timestamptz Bookkeeping (indexer ingestion wall-clock).

Index: inference_trades_tape_idx(orderbook_address, trade_id DESC). Backs the newest-first per-book read (ORDER BY trade_id DESC LIMIT $limit) as an index range scan. The table is insert-only; a replayed insert conflicts on trade_id and only coalesces a NULL chain_time (first-write-wins, guarded by every other column matching the recorded row — a divergent conflict is skipped and error!-logged rather than silently overwritten), so reprojection from raw_events is idempotent.

Recovery notes for on-call:

  • Row hidden by chain_time IS NULL (gateway delivered the fill without created_at): fix the row directly — UPDATE inference_trades SET chain_time = to_timestamp(...) WHERE trade_id = ..., sourcing the timestamp from the repaired raw_events.created_at_chain. As with trades, do not clear the event's processed_at to force a replay while the order rows are still live — the surrounding InferenceFilled projection is not replay-idempotent on inference_orders.amount_remaining.
  • Missing tape row for a known match: check whether resolve_is_buyer_maker had a leg to read — a row is only omitted when neither the maker nor the taker leg exists in inference_orders at projection time (both InferenceOrderPlaced events dropped at capture, the orphan-repair no-leg case logged at warn!).

Read-model — inference deals

The inference settlement side can track the lifecycle of each deal escrow (TokenContract — a per-deal streaming-payment contract auto-deployed when a SELL offer is matched) and the individual finalized ticks within it. The SETTLEMENT projector still replays TokenContract.* rows already retained in raw_events, but the current two-stream live capture excludes every TokenContract dst before decode and therefore does not add new settlement-event rows. These tables are not used by the current public inference endpoints.

inference_deals

One row per TokenContract address. InferenceOrderBook.InferenceFilled from the live DEX dApp stream creates or enriches the deal cross-link. If retained TokenContract.* rows are replayed, the first one seeds a skeleton keyed by src_address, and the SETTLEMENT projector fills the corresponding settlement columns.

Column Type Notes
token_contract_address text PK Address of the TokenContract escrow deployed when a SELL order is matched. The per-deal identifier.
orderbook_address text (nullable) The InferenceOrderBook address that matched this deal. Filled from InferenceOrderBook.InferenceFilled.
seller_note text (nullable) PrivateNote address of the seller. Filled from InferenceOrderBook.InferenceFilled.
buyer_note text (nullable) PrivateNote address of the buyer. Filled from InferenceOrderBook.InferenceFilled.
deposit numeric(78,0) (nullable) Initial deposit amount (quote token units).
price_per_tick numeric(78,0) (nullable) Agreed price per finalized tick (quote token units).
finalized_ticks integer NOT NULL default 0 Count of TokenContract.TickFinalized events for this deal (= number of inference_ticks rows). NOT the contract's on-chain _ticksFinalized, which additionally counts the probe-accept tick (ProbeAccepted) and the closing tick (folded into StreamStopped/StreamReclaimed/DisputeResolved), neither emitted as TickFinalized.
trusted_ticks numeric(78,0) (nullable) High-water mark of TokenContract.TicksClaimed.trusted — work the deal has actually credited. NULL until the seller first calls claimTokens.
claimed_ticks numeric(78,0) (nullable) High-water mark of TicksClaimed.claimed — what the seller has claimed but that has not yet been trusted or contested, so it runs ahead of trusted_ticks while a claim is pending. Both columns track the position between weekly boundaries, where finalized_ticks and inference_ticks say nothing: a subscription week is 604800 s, so a deal would otherwise look motionless for days. Kept as high-water marks because the chain's own claimTokens is cumulative and non-decreasing (require(cumulativeTokens >= _tokensPend2)), so a replayed or out-of-order event cannot walk them backwards.
funded_at_chain timestamptz (nullable) Chain timestamp of the funding event.
opened_at_chain timestamptz (nullable) Chain timestamp when the deal was opened.
settled_at_chain timestamptz (nullable) Chain timestamp when the deal closed cleanly or was resolved.
close_kind text (nullable) Terminal close type: one of STOPPED, DISPUTE_RESOLVED, RECLAIMED, DESTROYED, PROBE_BURNED. Enforced by a CHECK constraint.
clean_settlement boolean (nullable) true if the deal closed without a dispute; false or null otherwise.
disputed_at_chain timestamptz (nullable) Chain timestamp of the dispute event, if any.
last_chain_order text (nullable) The chain_order of the most recent InferenceOrderBook.InferenceFilled cross-link that wrote this row. Advanced via greatest(existing, new) by the InferenceFilled handler; not read or advanced by any TokenContract handler.
created_at / updated_at timestamptz Bookkeeping.

Indices:

Index Purpose
inference_deals_orderbook_idx Lookup all deals for a given InferenceOrderBook.
inference_deals_seller_idx Lookup all deals by seller PrivateNote — sized for per-seller aggregation queries (e.g. by the forthcoming rewards service).
inference_deals_buyer_idx Lookup all deals by buyer PrivateNote.

inference_ticks

One row per finalized tick within a deal. Written by the SETTLEMENT projector when a retained TokenContract.TickFinalized row is replayed. The composite PK (token_contract_address, chain_order) is idempotent against redelivery. Current live capture does not add these rows.

Column Type Notes
token_contract_address text NOT NULL (part of PK) FK → inference_deals(token_contract_address) ON DELETE CASCADE Parent deal's TokenContract address. The projector always inserts the parent inference_deals row before any tick insert, so the FK is always satisfiable at runtime. ON DELETE CASCADE makes test cleanup order-independent.
chain_order text NOT NULL (part of PK) The chain_order of the TickFinalized event. Uniquely identifies each tick within a deal.
finalized_owed numeric(78,0) NOT NULL Cumulative SHELL-to-seller total (_finalizedOwed) as carried by the TickFinalized event — a running total at the moment of this tick, NOT a per-tick delta. Do not sum across rows.
deposit numeric(78,0) NOT NULL Deposit snapshot at tick finalization.
chain_at timestamptz (nullable) Chain timestamp of the finalization event.
created_at timestamptz NOT NULL default now() Bookkeeping.

Indices:

Index Purpose
inference_ticks_tc_idx Fetch all ticks for a deal by token_contract_address.

Authentication and credentials

Identity and credential storage for the auth middleware. See auth.md for the user model, request-verification pipeline, and error mapping.

accounts

One row per logical user. Holds the custodied trading PrivateNote inline; multiple PNs per account are not supported in this version and replacing the PN is operator-only via direct UPDATE on this row.

Column Type Notes
id uuid PK default gen_random_uuid() Stable accountId surfaced to clients. The only identifier that crosses the API boundary.
label text (nullable) Operator-facing label. Not exposed by the API.
pn_address text UNIQUE Address of the trading PrivateNote bound to this account. Source of balances for GET /api/v1/account and GET /api/v1/account/balances.
pn_pubkey numeric(78, 0) PN signing pubkey.
pn_seckey_enc bytea PN signing seckey, encrypted at rest under the backend master key (crates/infrastructure/src/crypto.rs). Never read by the API; used by the trading path to submit transactions.
pn_dih numeric(78, 0) UNIQUE Deploy-init hash of the PN. Disambiguates PNs that may share an address across redeploys.
disabled_at timestamptz (nullable) Soft-disable marker. NULL = active.
created_at timestamptz default now() Bookkeeping.

api_keys

API credential pairs. Multiple per account, each with its own permission set. The api_secret is generated at issuance and only the ciphertext is stored; the cleartext is shown to the operator once and cannot be recovered later.

Column Type Notes
id bigserial PK Internal identifier. Never surfaced.
account_id uuid FK → accounts(id) ON DELETE CASCADE Owning account.
api_key text Public half of the credential pair; sent by clients in the X-DODEX-APIKEY header.
api_secret_enc bytea Encrypted api_secret. Decrypted in-process to recompute the request HMAC.
permissions auth_permission[] default {USER_DATA} Subset of the auth_permission enum (USER_DATA, TRADE). Endpoints declare a required permission; auth rejects with -1002 if the key lacks it.
disabled_at timestamptz (nullable) Soft-disable marker. Disabled keys are rejected with -1002. NULL = active.
last_used_at timestamptz (nullable) Stamped by the auth middleware on successful verification. Used for operator audits and stale-key cleanup.
created_at timestamptz default now() Bookkeeping.

Indices:

  • api_keys_api_key_active_idx — UNIQUE partial index on (api_key) WHERE disabled_at IS NULL. Lets the auth middleware look up an active credential by api_key in O(1) without colliding with historical disabled rows that may have reused the same string (irrelevant in practice with 256-bit random keys, but the partial predicate captures the exact invariant).
  • api_keys_account_id_idx — supports operator queries that list all keys under an account.

System tables

_sqlx_migrations is created and maintained by sqlx::migrate!. It records which migration files have been applied. Do not touch it in application code.

Schema evolution

Every schema change ships as a new numbered migration file. Conventions:

  • Use if not exists / if exists on DDL so re-runs are idempotent.
  • For new columns, prefer add column if not exists with a sensible default — never break startup on an empty database.
  • Partial indices are preferred over full ones for "pending row" predicates; they shrink with reconciliation progress.
  • Add a header comment on every migration explaining why the change is needed and which code path requires it. Migrations are read by reviewers and operators as much as the code is.
  • Pre-deploy check: index builds inside sqlx transactions can block writers until the build finishes. For hot tables, estimate the lock window from production row counts before deploying or run the change through a non-transactional migration path.

The full migration set (migrations/*.sql) is the canonical reference; this document summarises intent but does not replace it.