Преглед на файлове

Merge active and reserved postings into one live_postings hot index

Merge active_postings and reserved_postings into one live_postings table with
a nullable reservation (NULL = Active, set = Reserved by that reservation).
Reserve and release become a single UPDATE guarded by WHERE reservation IS
NULL, a live read is one index scan instead of a UNION ALL, and
get_posting_states probes one table instead of three. The append-only postings
value table is untouched.

Add ADR 0022 for the hot-index merge and doc/storage-schema.md as the composed
schema reference.
Cesar Rodas преди 1 седмица
родител
ревизия
1146f97f5f

+ 4 - 0
crates/kuatia-storage-sql/src/migrate.rs

@@ -44,6 +44,10 @@ impl SqlStore {
                 "007_balance_projection",
                 include_str!("migrations/007_balance_projection.sql"),
             ),
+            (
+                "008_live_postings",
+                include_str!("migrations/008_live_postings.sql"),
+            ),
         ];
 
         for (name, sql) in migrations {

+ 29 - 0
crates/kuatia-storage-sql/src/migrations/008_live_postings.sql

@@ -0,0 +1,29 @@
+-- Merge the two hot index tables (active_postings, reserved_postings) into one
+-- `live_postings` table with a nullable `reservation`: NULL = Active, set =
+-- Reserved by that reservation id. A posting's state is still derived from
+-- membership (present + NULL = Active, present + rid = Reserved, absent but in
+-- `postings` = Spent), but the live set now lives in a single table, so a
+-- spendable/live read is one index scan instead of a UNION ALL of two tables.
+-- Reserve/release become a single UPDATE of `reservation` (an atomic
+-- single-winner claim via `WHERE reservation IS NULL`) instead of moving a row
+-- between two tables. The value table `postings` stays append-only. See ADR-0022.
+CREATE TABLE live_postings (
+    transfer_id TEXT NOT NULL,
+    idx         SMALLINT NOT NULL,
+    owner       BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    asset       INTEGER NOT NULL,
+    value       TEXT NOT NULL,
+    reservation BIGINT,
+    PRIMARY KEY (transfer_id, idx)
+);
+
+INSERT INTO live_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) SELECT transfer_id, idx, owner, subaccount, asset, value, NULL FROM active_postings;
+
+INSERT INTO live_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) SELECT transfer_id, idx, owner, subaccount, asset, value, reservation FROM reserved_postings;
+
+CREATE INDEX IF NOT EXISTS idx_live_owner ON live_postings(owner, subaccount, asset);
+
+DROP TABLE active_postings;
+
+DROP TABLE reserved_postings;

+ 92 - 151
crates/kuatia-storage-sql/src/posting.rs

@@ -1,13 +1,12 @@
-//! [`PostingStore`]: the immutable posting record plus two hot index tables
-//! (`active_postings`, `reserved_postings`) whose membership derives each
-//! posting's lifecycle state.
+//! [`PostingStore`]: the immutable posting record plus one `live_postings` hot
+//! table whose `reservation` column derives each posting's lifecycle state.
 //!
-//! A posting is in `active_postings` while spendable, moves to
-//! `reserved_postings` (carrying its reservation) while claimed by a saga, and
-//! once consumed is deleted from both, leaving it only in the immutable
-//! `postings` table (= Spent). See ADR-0016.
+//! A posting is in `live_postings` while it is spendable or reserved:
+//! `reservation IS NULL` = Active, `reservation = rid` = Reserved by that saga.
+//! Once consumed the row is deleted, leaving the posting only in the immutable
+//! `postings` table (= Spent). See ADR-0022 (merged hot index) and ADR-0016.
 
-use std::collections::{HashMap, HashSet};
+use std::collections::HashMap;
 
 use async_trait::async_trait;
 use sqlx::Row;
@@ -20,21 +19,16 @@ use kuatia_types::*;
 use crate::SqlStore;
 use crate::row::{envelope_id_to_hex, row_to_posting};
 
-/// The FROM source for a posting read of the given derived state. Each index
-/// table carries a full row copy, so the live-set reads target the index table
-/// directly with no merge back to the immutable `postings` record. `Live` is a
-/// `UNION ALL` of the two disjoint live sets (the shared 6 data columns), still
-/// with no join to history. Portable across SQLite and PostgreSQL.
-fn filter_source(filter: PostingFilter) -> &'static str {
+/// The `(source_table, state_predicate)` a posting read of the given filter
+/// targets. Live/Active/Reserved read the single `live_postings` hot table,
+/// narrowed by the `reservation` column; `All` reads the immutable record. No
+/// UNION: the live set is one table.
+fn filter_source(filter: PostingFilter) -> (&'static str, &'static str) {
     match filter {
-        PostingFilter::Active => "active_postings",
-        PostingFilter::Reserved => "reserved_postings",
-        PostingFilter::All => "postings",
-        PostingFilter::Live => {
-            "(SELECT transfer_id, idx, owner, subaccount, asset, value FROM active_postings \
-             UNION ALL \
-             SELECT transfer_id, idx, owner, subaccount, asset, value FROM reserved_postings) AS live"
-        }
+        PostingFilter::Active => ("live_postings", " AND reservation IS NULL"),
+        PostingFilter::Reserved => ("live_postings", " AND reservation IS NOT NULL"),
+        PostingFilter::Live => ("live_postings", ""),
+        PostingFilter::All => ("postings", ""),
     }
 }
 
@@ -65,10 +59,6 @@ fn id_predicate(count: usize, start: u32) -> String {
         .join(" OR ")
 }
 
-// ---------------------------------------------------------------------------
-// PostingStore
-// ---------------------------------------------------------------------------
-
 #[async_trait]
 impl PostingStore for SqlStore {
     async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError> {
@@ -130,9 +120,10 @@ impl PostingStore for SqlStore {
     ) -> Result<Vec<Posting>, StoreError> {
         // Build the predicate dynamically: `sub == None` spans every subaccount
         // of `id`, `Some(s)` restricts to one. The subaccount is compared only
-        // for equality, never as a magnitude. The derived-state filter selects
-        // which table (index copy or immutable record) to read from directly.
-        let mut sql = format!("SELECT * FROM {} WHERE owner = $1", filter_source(filter));
+        // for equality, never as a magnitude. The filter picks the source table
+        // and, for the live table, the `reservation` state predicate.
+        let (source, state) = filter_source(filter);
+        let mut sql = format!("SELECT * FROM {source} WHERE owner = $1");
         let mut placeholder = 2u32;
         if sub.is_some() {
             sql.push_str(&format!(" AND subaccount = ${placeholder}"));
@@ -141,6 +132,7 @@ impl PostingStore for SqlStore {
         if asset.is_some() {
             sql.push_str(&format!(" AND asset = ${placeholder}"));
         }
+        sql.push_str(state);
         // Deterministic order by the posting primary key, matching
         // `query_postings`, so callers (and pagination built on top) see a
         // stable sequence.
@@ -166,11 +158,12 @@ impl PostingStore for SqlStore {
             return Ok(Vec::new());
         }
 
-        // One set-based query per state table instead of up to three probes per
-        // id, reusing the portable `id_predicate` (an OR of equality pairs;
-        // row-value `IN` is not portable across SQLite and PostgreSQL) and
-        // binding every id in order as `(hex(transfer), idx as i16)`. Chunked so
-        // a large batch never exceeds the bind-parameter limit.
+        // Two set-based queries per chunk (was three, before the hot tables were
+        // merged): the live table carries both Active (`reservation IS NULL`) and
+        // Reserved (`reservation = rid`) in one row, and `postings` decides Spent
+        // vs Missing for ids absent from the live set. Reuses the portable
+        // `id_predicate` (an OR of equality pairs; row-value `IN` is not portable
+        // across SQLite and PostgreSQL), binding each id as `(hex, idx)`.
 
         // Key membership by the same `(hex, idx)` values that were bound, so the
         // per-id lookup below matches without decoding transfer ids back.
@@ -184,62 +177,50 @@ impl PostingStore for SqlStore {
             Ok((transfer_id, idx))
         };
 
-        let mut active: HashSet<(String, i16)> = HashSet::new();
-        let mut reserved: HashMap<(String, i16), i64> = HashMap::new();
-        let mut spent: HashSet<(String, i16)> = HashSet::new();
+        // `live` maps each present id to its reservation: `None` = Active,
+        // `Some(rid)` = Reserved. `present` is every id in the immutable record,
+        // used to tell Spent (present, not live) from Missing (absent).
+        let mut live: HashMap<(String, i16), Option<i64>> = HashMap::new();
+        let mut present: std::collections::HashSet<(String, i16)> =
+            std::collections::HashSet::new();
 
         for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
             let predicate = id_predicate(chunk.len(), 1);
 
-            let active_sql =
-                format!("SELECT transfer_id, idx FROM active_postings WHERE {predicate}");
-            let mut active_q = sqlx::query(&active_sql);
-            for id in chunk {
-                active_q = active_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            let active_rows = active_q
-                .fetch_all(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            for row in &active_rows {
-                active.insert(row_key(row)?);
-            }
-
-            let reserved_sql = format!(
-                "SELECT transfer_id, idx, reservation FROM reserved_postings WHERE {predicate}"
+            let live_sql = format!(
+                "SELECT transfer_id, idx, reservation FROM live_postings WHERE {predicate}"
             );
-            let mut reserved_q = sqlx::query(&reserved_sql);
+            let mut live_q = sqlx::query(&live_sql);
             for id in chunk {
-                reserved_q = reserved_q
+                live_q = live_q
                     .bind(envelope_id_to_hex(&id.transfer))
                     .bind(id.index as i16);
             }
-            let reserved_rows = reserved_q
+            let live_rows = live_q
                 .fetch_all(&self.pool)
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
-            for row in &reserved_rows {
-                let rid: i64 = row
+            for row in &live_rows {
+                // `reservation` is nullable: NULL decodes to `None` = Active.
+                let rid: Option<i64> = row
                     .try_get("reservation")
                     .map_err(|e| StoreError::Internal(e.to_string()))?;
-                reserved.insert(row_key(row)?, rid);
+                live.insert(row_key(row)?, rid);
             }
 
-            let spent_sql = format!("SELECT transfer_id, idx FROM postings WHERE {predicate}");
-            let mut spent_q = sqlx::query(&spent_sql);
+            let present_sql = format!("SELECT transfer_id, idx FROM postings WHERE {predicate}");
+            let mut present_q = sqlx::query(&present_sql);
             for id in chunk {
-                spent_q = spent_q
+                present_q = present_q
                     .bind(envelope_id_to_hex(&id.transfer))
                     .bind(id.index as i16);
             }
-            let spent_rows = spent_q
+            let present_rows = present_q
                 .fetch_all(&self.pool)
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
-            for row in &spent_rows {
-                spent.insert(row_key(row)?);
+            for row in &present_rows {
+                present.insert(row_key(row)?);
             }
         }
 
@@ -248,14 +229,11 @@ impl PostingStore for SqlStore {
         let mut out = Vec::with_capacity(ids.len());
         for id in ids {
             let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
-            out.push(if active.contains(&key) {
-                PostingState::Active
-            } else if let Some(rid) = reserved.get(&key) {
-                PostingState::Reserved(ReservationId::new(*rid))
-            } else if spent.contains(&key) {
-                PostingState::Spent
-            } else {
-                PostingState::Missing
+            out.push(match live.get(&key) {
+                Some(None) => PostingState::Active,
+                Some(Some(rid)) => PostingState::Reserved(ReservationId::new(*rid)),
+                None if present.contains(&key) => PostingState::Spent,
+                None => PostingState::Missing,
             });
         }
         Ok(out)
@@ -263,7 +241,7 @@ impl PostingStore for SqlStore {
 
     async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
         let (where_clause, count_clause) = {
-            let source = filter_source(query.filter);
+            let (source, state) = filter_source(query.filter);
             let mut w = String::from("WHERE owner = $1");
             let mut idx = 2u32;
             if query.sub.is_some() {
@@ -273,13 +251,12 @@ impl PostingStore for SqlStore {
             if query.asset.is_some() {
                 w.push_str(&format!(" AND asset = ${idx}"));
             }
+            w.push_str(state);
             let c = format!("SELECT COUNT(*) as cnt FROM {source} {w}");
             let limit = query.limit.unwrap_or(u32::MAX);
             let offset = query.offset.unwrap_or(0);
             // Order by the posting primary key so pagination is deterministic:
-            // without it LIMIT/OFFSET could skip or repeat rows across pages,
-            // especially for `Live`, whose source is a `UNION ALL` with no
-            // inherent order.
+            // without it LIMIT/OFFSET could skip or repeat rows across pages.
             w.push_str(&format!(
                 " ORDER BY transfer_id, idx LIMIT {limit} OFFSET {offset}"
             ));
@@ -327,13 +304,12 @@ impl PostingStore for SqlStore {
         ids: &[PostingId],
         reservation: ReservationId,
     ) -> Result<u64, StoreError> {
-        // Dumb instruction over the whole id set, in two statements: copy the
-        // currently-active rows into the reserved index (sourced from
-        // `active_postings`, so only active ids move), then delete those same
-        // ids from `active_postings`. The DELETE's affected count is the number
-        // claimed, and by active/reserved disjointness it equals the INSERT's
-        // row count. Concurrent reserves serialize on the reserved-index primary
-        // key, so exactly one wins each contended id.
+        // Dumb instruction over the whole id set: flip each still-Active row's
+        // `reservation` from NULL to this saga's id. `WHERE reservation IS NULL`
+        // is the atomic single-winner claim: concurrent reserves serialize on the
+        // row lock, and the loser's predicate no longer matches, so exactly one
+        // wins each contended id. `rows_affected` is the number claimed; an
+        // already-reserved or spent id does not match and is not counted.
         if ids.is_empty() {
             return Ok(0);
         }
@@ -348,38 +324,21 @@ impl PostingStore for SqlStore {
         let mut claimed: u64 = 0;
         for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
             // Reservation is $1; each id pair follows starting at $2.
-            let insert_sql = format!(
-                "INSERT INTO reserved_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) \
-                 SELECT transfer_id, idx, owner, subaccount, asset, value, $1 FROM active_postings WHERE {} \
-                 ON CONFLICT (transfer_id, idx) DO NOTHING",
+            let sql = format!(
+                "UPDATE live_postings SET reservation = $1 WHERE ({}) AND reservation IS NULL",
                 id_predicate(chunk.len(), 2)
             );
-            let mut insert_q = sqlx::query(&insert_sql).bind(reservation.0);
-            for id in chunk {
-                insert_q = insert_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            insert_q
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-            let delete_sql = format!(
-                "DELETE FROM active_postings WHERE {}",
-                id_predicate(chunk.len(), 1)
-            );
-            let mut delete_q = sqlx::query(&delete_sql);
+            let mut q = sqlx::query(&sql).bind(reservation.0);
             for id in chunk {
-                delete_q = delete_q
+                q = q
                     .bind(envelope_id_to_hex(&id.transfer))
                     .bind(id.index as i16);
             }
-            let del = delete_q
+            let res = q
                 .execute(&mut *tx)
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
-            claimed += del.rows_affected();
+            claimed += res.rows_affected();
         }
 
         tx.commit()
@@ -393,10 +352,10 @@ impl PostingStore for SqlStore {
         ids: &[PostingId],
         reservation: ReservationId,
     ) -> Result<u64, StoreError> {
-        // Dumb instruction over the whole id set: copy the rows reserved by
-        // `reservation` back into the active index, then delete them from the
-        // reserved index. The DELETE's affected count is the number released; an
-        // id already active or reserved by another saga does not match.
+        // Dumb instruction over the whole id set: clear the `reservation` of the
+        // rows this saga holds, returning them to Active. `rows_affected` is the
+        // number released; an id already Active or reserved by another saga does
+        // not match `reservation = rid` and is left untouched.
         if ids.is_empty() {
             return Ok(0);
         }
@@ -406,43 +365,24 @@ impl PostingStore for SqlStore {
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
 
-        // Chunked so a large id set stays under the bind-parameter limit; all
-        // chunks share one transaction.
         let mut released: u64 = 0;
         for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
             // Reservation is $1; each id pair follows starting at $2.
-            let insert_sql = format!(
-                "INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) \
-                 SELECT transfer_id, idx, owner, subaccount, asset, value FROM reserved_postings \
-                 WHERE ({}) AND reservation = $1 ON CONFLICT (transfer_id, idx) DO NOTHING",
-                id_predicate(chunk.len(), 2)
-            );
-            let mut insert_q = sqlx::query(&insert_sql).bind(reservation.0);
-            for id in chunk {
-                insert_q = insert_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            insert_q
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-            let delete_sql = format!(
-                "DELETE FROM reserved_postings WHERE ({}) AND reservation = $1",
+            let sql = format!(
+                "UPDATE live_postings SET reservation = NULL WHERE ({}) AND reservation = $1",
                 id_predicate(chunk.len(), 2)
             );
-            let mut delete_q = sqlx::query(&delete_sql).bind(reservation.0);
+            let mut q = sqlx::query(&sql).bind(reservation.0);
             for id in chunk {
-                delete_q = delete_q
+                q = q
                     .bind(envelope_id_to_hex(&id.transfer))
                     .bind(id.index as i16);
             }
-            let del = delete_q
+            let res = q
                 .execute(&mut *tx)
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
-            released += del.rows_affected();
+            released += res.rows_affected();
         }
 
         tx.commit()
@@ -456,11 +396,11 @@ impl PostingStore for SqlStore {
         ids: &[PostingId],
         reservation: Option<ReservationId>,
     ) -> Result<u64, StoreError> {
-        // Dumb instruction over the whole id set: a DELETE removes the ids from
-        // an index so they become spent (present only in the immutable table).
-        // `rows_affected` is the count; the caller interprets a shortfall.
-        // Chunked under one transaction so a large id set stays within the
-        // bind-parameter limit while the removal stays atomic.
+        // Dumb instruction over the whole id set: DELETE the ids from the live
+        // table so they become spent (present only in the immutable `postings`).
+        // The raw path removes still-Active rows (`reservation IS NULL`); the saga
+        // path removes only the rows reserved by `rid`. `rows_affected` is the
+        // count; the caller interprets a shortfall. Chunked under one transaction.
         if ids.is_empty() {
             return Ok(0);
         }
@@ -472,10 +412,10 @@ impl PostingStore for SqlStore {
         let mut removed: u64 = 0;
         for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
             let (sql, rid) = match reservation {
-                // Raw path: remove from the active index.
+                // Raw path: remove still-Active rows.
                 None => (
                     format!(
-                        "DELETE FROM active_postings WHERE {}",
+                        "DELETE FROM live_postings WHERE ({}) AND reservation IS NULL",
                         id_predicate(chunk.len(), 1)
                     ),
                     None,
@@ -483,7 +423,7 @@ impl PostingStore for SqlStore {
                 // Saga path: remove only the rows reserved by `rid`.
                 Some(rid) => (
                     format!(
-                        "DELETE FROM reserved_postings WHERE ({}) AND reservation = $1",
+                        "DELETE FROM live_postings WHERE ({}) AND reservation = $1",
                         id_predicate(chunk.len(), 2)
                     ),
                     Some(rid),
@@ -512,9 +452,10 @@ impl PostingStore for SqlStore {
 
     async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError> {
         // Dumb instruction: insert each posting into the immutable table and, only
-        // when the row was newly inserted, add its id to the active index. Return
-        // the count of immutable rows inserted. The newness gate stops a replayed
-        // finalize from re-activating a since-spent posting.
+        // when the row was newly inserted, add it to the live table as Active
+        // (reservation NULL). Return the count of immutable rows inserted. The
+        // newness gate stops a replayed finalize from re-activating a since-spent
+        // posting.
         let mut tx = self
             .pool
             .begin()
@@ -536,9 +477,9 @@ impl PostingStore for SqlStore {
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
             if res.rows_affected() == 1 {
-                // Activate a full copy so spendable reads never merge.
+                // Activate a full copy (reservation NULL) so spendable reads never merge.
                 sqlx::query(
-                    "INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING",
+                    "INSERT INTO live_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) VALUES ($1, $2, $3, $4, $5, $6, NULL) ON CONFLICT (transfer_id, idx) DO NOTHING",
                 )
                 .bind(hex)
                 .bind(posting.id.index as i16)

+ 109 - 5
crates/kuatia-storage-sql/tests/sqlite.rs

@@ -209,8 +209,7 @@ async fn migration_004_backfills_index_tables() {
         .await
         .unwrap();
 
-    // A post-003 DB also has the accounts table (empty here); migrate() will run
-    // 005 after 004, which backfills the account head from it.
+    // A post-003 DB also has the accounts table (empty here).
     sqlx::query(
         "CREATE TABLE accounts (id BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, version BIGINT NOT NULL, policy TEXT NOT NULL, flags INTEGER NOT NULL, book BIGINT NOT NULL, metadata TEXT NOT NULL, PRIMARY KEY (id, subaccount, version))",
     )
@@ -218,12 +217,23 @@ async fn migration_004_backfills_index_tables() {
     .await
     .unwrap();
 
-    // Record 001-003 as applied so migrate() only runs 004 and 005.
+    // Record every migration except 004 as applied so migrate() runs only 004,
+    // isolating its backfill. In particular 008 (which merges the index tables
+    // into `live_postings`) is pinned as applied so it does not run and clobber
+    // the active/reserved tables this test inspects.
     sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
         .execute(&pool)
         .await
         .unwrap();
-    for m in ["001_init", "002_subaccounts", "003_drop_user_data"] {
+    for m in [
+        "001_init",
+        "002_subaccounts",
+        "003_drop_user_data",
+        "005_account_head",
+        "006_drop_policy",
+        "007_balance_projection",
+        "008_live_postings",
+    ] {
         sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
             .bind(m)
             .execute(&pool)
@@ -298,7 +308,9 @@ async fn migration_005_backfills_account_head() {
             .unwrap();
     }
 
-    // Record 001-004 as applied so migrate() only runs 005.
+    // Record every migration except 005 as applied so migrate() runs only 005.
+    // 008 is pinned as applied so it does not run against a DB whose 004 index
+    // tables were never created.
     sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
         .execute(&pool)
         .await
@@ -308,6 +320,9 @@ async fn migration_005_backfills_account_head() {
         "002_subaccounts",
         "003_drop_user_data",
         "004_index_tables",
+        "006_drop_policy",
+        "007_balance_projection",
+        "008_live_postings",
     ] {
         sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
             .bind(m)
@@ -335,6 +350,95 @@ async fn migration_005_backfills_account_head() {
     assert_eq!(acct.version, 3);
 }
 
+/// The 008 migration merges the two index tables into one `live_postings`
+/// table: an active row is backfilled with `reservation` NULL, a reserved row
+/// keeps its token, and the old `active_postings`/`reserved_postings` tables are
+/// dropped.
+#[tokio::test]
+async fn migration_008_merges_live_postings() {
+    let pool = new_pool().await;
+
+    // Post-004 index tables with one active and one reserved posting.
+    sqlx::query(
+        "CREATE TABLE active_postings (transfer_id TEXT NOT NULL, idx SMALLINT NOT NULL, owner BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, asset INTEGER NOT NULL, value TEXT NOT NULL, PRIMARY KEY (transfer_id, idx))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    sqlx::query(
+        "CREATE TABLE reserved_postings (transfer_id TEXT NOT NULL, idx SMALLINT NOT NULL, owner BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, asset INTEGER NOT NULL, value TEXT NOT NULL, reservation BIGINT NOT NULL, PRIMARY KEY (transfer_id, idx))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    sqlx::query("INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ('aa', 0, 1, 0, 1, '100')")
+        .execute(&pool)
+        .await
+        .unwrap();
+    sqlx::query("INSERT INTO reserved_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) VALUES ('bb', 0, 1, 0, 1, '200', 77)")
+        .execute(&pool)
+        .await
+        .unwrap();
+
+    // Record every migration except 008 as applied so migrate() runs only 008.
+    sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
+        .execute(&pool)
+        .await
+        .unwrap();
+    for m in [
+        "001_init",
+        "002_subaccounts",
+        "003_drop_user_data",
+        "004_index_tables",
+        "005_account_head",
+        "006_drop_policy",
+        "007_balance_projection",
+    ] {
+        sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
+            .bind(m)
+            .execute(&pool)
+            .await
+            .unwrap();
+    }
+
+    let store = SqlStore::new(pool.clone());
+    store.migrate().await.unwrap();
+
+    // The active row lands with reservation NULL, the reserved row keeps its token.
+    let mut rows: Vec<(String, Option<i64>)> =
+        sqlx::query("SELECT transfer_id, reservation FROM live_postings")
+            .fetch_all(&pool)
+            .await
+            .unwrap()
+            .iter()
+            .map(|r| {
+                (
+                    r.try_get("transfer_id").unwrap(),
+                    r.try_get("reservation").unwrap(),
+                )
+            })
+            .collect();
+    rows.sort();
+    assert_eq!(
+        rows,
+        vec![("aa".to_string(), None), ("bb".to_string(), Some(77))]
+    );
+
+    // The old index tables are gone.
+    assert!(
+        sqlx::query("SELECT 1 FROM active_postings")
+            .fetch_all(&pool)
+            .await
+            .is_err()
+    );
+    assert!(
+        sqlx::query("SELECT 1 FROM reserved_postings")
+            .fetch_all(&pool)
+            .await
+            .is_err()
+    );
+}
+
 /// migrate() is idempotent: running it repeatedly on the same DB is a no-op.
 #[tokio::test]
 async fn migrate_is_idempotent() {

+ 151 - 0
doc/adr/0022-merged-live-postings-hot-index.md

@@ -0,0 +1,151 @@
+# Merge the active/reserved hot indexes into one `live_postings` table
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-08-01
+* Targeted modules: `kuatia-storage-sql` (schema, migration 008,
+  `PostingStore`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0016/0017 split a posting's live state across two disposable hot tables:
+`active_postings` (membership = spendable) and `reserved_postings` (membership +
+`reservation` = claimed by a saga). A "live" read (`Active ∪ Reserved`), which
+`compute_balance` and `has_live_postings` need, therefore has no single table to
+hit: the SQL backend expresses it as a `UNION ALL` of the two tables. Reserve and
+release move a row between the two tables (`DELETE` from one, `INSERT` into the
+other), and `get_posting_states` probes three tables.
+
+Only the SQL encoding forces the `UNION ALL`; the two-table split is not required
+by any correctness guarantee (ADR-0006's reservation protocol needs an atomic
+single-winner claim, durable observable ownership, and count-returning
+primitives, none of which depend on the physical table count). Can the live set
+live in one table so the `UNION ALL` disappears, without losing those
+guarantees?
+
+## Decision Drivers
+
+* **Query clarity and read performance.** The live/spendable reads are hot
+  (`compute_balance` runs per commit in the finalize validation loader). A
+  single-table scan is simpler to reason about and to index than a `UNION ALL`.
+* **Preserve every ADR-0006/0016 guarantee.** Unconditional lock-free
+  double-spend safety; durable, observable, recoverable reservation ownership;
+  atomic count-returning dumb-storage primitives (ADR-0003); balance as
+  `Active ∪ Reserved`; append-only value table.
+* **Keep the source of truth append-only (ADR-0017).** `postings` (the immutable
+  record) must stay INSERT-only and rebuildable-from is the audit trail. Whatever
+  changes may only touch a disposable hot table.
+
+## Considered Options
+
+#### Option 1: Keep two hot tables (ADR-0016/0017)
+
+`active_postings` + `reserved_postings`, live reads via `UNION ALL`, reserve as
+`DELETE`-from-active + `INSERT`-into-reserved.
+
+**Pros:**
+
+* Good, because the entire hot-table write path is `INSERT` + `DELETE`, so a
+  grant can withhold `UPDATE` on them too.
+* Good, because it is already implemented and conformance-tested.
+
+**Cons:**
+
+* Bad, because a live read has no single table and is a `UNION ALL`.
+* Bad, because reserve/release are two-statement moves and `get_posting_states`
+  probes three tables.
+
+#### Option 2: One `live_postings` table with a nullable `reservation`
+
+Merge the two hot tables into one: `live_postings (transfer_id, idx, owner,
+subaccount, asset, value, reservation)`, `reservation` NULL = Active, set =
+Reserved by that id. State is still derived from membership + the column: present
+& NULL = Active; present & = rid = Reserved(rid); absent from `live_postings` but
+in `postings` = Spent; absent everywhere = Missing. The primitives become:
+
+* Live read: `SELECT ... FROM live_postings WHERE owner/subaccount/asset` (no
+  `UNION`). Active = `... AND reservation IS NULL`, Reserved = `... AND
+  reservation IS NOT NULL`.
+* Reserve: `UPDATE live_postings SET reservation = $rid WHERE <ids> AND
+  reservation IS NULL`. The `IS NULL` guard is the atomic single-winner claim
+  (concurrent reserves serialize on the row lock; the loser's predicate no longer
+  matches → 0 rows). One statement.
+* Release: `UPDATE live_postings SET reservation = NULL WHERE <ids> AND
+  reservation = $rid`.
+* Consume (finalize) / raw deactivate: `DELETE FROM live_postings WHERE <ids> AND
+  reservation = $rid` (or `IS NULL`); the posting stays in `postings` = Spent.
+* `get_posting_states`: two statements (live table + `postings`) instead of three.
+
+**Pros:**
+
+* Good, because the live set is one table: the `UNION ALL` is gone, reserve and
+  release are single statements, and `get_posting_states` drops a statement.
+* Good, because every ADR-0006 guarantee holds: the `UPDATE ... WHERE reservation
+  IS NULL` gives the same single-winner claim as the `DELETE`-CAS; the
+  `reservation` column is still the durable, observable ownership recovery reads;
+  the primitives still return affected-row counts the saga interprets.
+* Good, because the value table `postings` stays append-only INSERT-only, and
+  `live_postings` is still fully rebuildable from `postings` plus the saga
+  write-ahead records, so a corrupt hot table is drop-and-rebuild (ADR-0017's
+  disposability principle is preserved).
+
+**Cons:**
+
+* Bad, because the hot table now takes an `UPDATE` (the reservation flip), so the
+  "hot tables are `INSERT`/`DELETE` only, withhold `UPDATE` everywhere" grant
+  story of ADR-0017 no longer holds for `live_postings` (it still holds for the
+  value tables).
+* Bad, because the schema change is forward-only (migration 008 merges the two
+  tables and drops them).
+
+#### Option 3: Keep the `UNION ALL` but hide it behind a view
+
+A SQL `VIEW live_postings AS active UNION ALL reserved`.
+
+**Pros:**
+
+* Good, because callers read one name.
+
+**Cons:**
+
+* Bad, because it is the same `UNION ALL` at runtime; nothing is actually saved.
+
+## Decision Outcome
+
+Chosen option: **Option 2, one `live_postings` table with a nullable
+`reservation`.** It removes the `UNION ALL` and collapses reserve/release to a
+single statement while preserving every correctness guarantee of ADR-0006/0016.
+The one concession is that the disposable hot table now takes an `UPDATE` (the
+reservation flip): this is deliberately confined to the rebuildable hot table and
+never touches the append-only `postings` value table, so ADR-0017's core driver
+(the source of truth cannot be corrupted or lost by any write path) is intact.
+
+This supersedes the two-table hot-index *encoding* of ADR-0016 and ADR-0017. Their
+principle (append-only value tables plus disposable hot indexes) stands; only the
+number of hot tables and the reserve verb change.
+
+### Positive Consequences
+
+* A live/spendable read is one index scan on `idx_live_owner`, no `UNION ALL`.
+* Reserve and release are single `UPDATE` statements; `get_posting_states` is two
+  queries instead of three.
+* `reservation IS NULL` vs `= rid` still expresses Active vs Reserved, so state is
+  still derived, and recovery still reads "reserved by this saga" from the column.
+
+### Negative Consequences
+
+* The `live_postings` hot table takes an `UPDATE`; the grant that withholds
+  `UPDATE` now applies to the value tables only, not the hot table.
+* Forward-only migration (008) that drops `active_postings` / `reserved_postings`.
+
+## Links
+
+* Supersedes the two-table hot-index encoding of
+  [ADR-0016](0016-immutable-postings-index-tables.md) and
+  [ADR-0017](0017-correctness-first-append-only-hot-indexes.md); their
+  append-only-value / disposable-hot-index principle is unchanged.
+* Preserves the reservation protocol of
+  [ADR-0006](0006-reservation-protocol-posting-lifecycle.md) and the dumb-storage
+  primitives of [ADR-0003](0003-dumb-storage-saga-recovery.md).
+* Reflected in [storage-schema.md](../storage-schema.md).

+ 331 - 0
doc/storage-schema.md

@@ -0,0 +1,331 @@
+# SQL storage schema
+
+Reference for the tables `kuatia-storage-sql` creates. This is the composed
+end-state after replaying every migration in
+`crates/kuatia-storage-sql/src/migrations/` (`001_init` through
+`007_balance_projection`) plus the `_migrations` bookkeeping table created in
+`crates/kuatia-storage-sql/src/migrate.rs`. It is a living reference: update it
+when a migration lands. For *why* the schema looks this way, see the ADRs it
+links to, not this file.
+
+Ground rules that keep the catalog terse:
+
+- **The store is a dumb record-keeper, so no foreign keys are declared.** Every
+  cross-table relationship below is logical, enforced in Rust, not by the
+  database. A posting's identity is `transfer_id + idx`; `account_head` points at
+  the current `accounts` version; the account/transfer link is an explicit index
+  table. See [ADR-0003](adr/0003-dumb-storage-saga-recovery.md).
+- **Every payload, money, and hash column is `TEXT`.** Content-addressed ids and
+  opaque saga bytes are lower-case hex; JSON payloads are their `TEXT`
+  serialization; a `Cent` is its decimal string. The store never does arithmetic
+  or `SUM`/`MAX` on these; balances are computed in Rust.
+- **All ids are Rust-minted `BIGINT`.** No `AUTOINCREMENT` / `SERIAL`; snowflake
+  ids come from `AutoId`. See [ADR-0015](adr/0015-fixed-width-account-code.md).
+- The identical DDL runs on both SQLite and PostgreSQL (`sqlx::Any`).
+
+Design rationale lives in [ADR-0016 (immutable postings + index
+tables)](adr/0016-immutable-postings-index-tables.md), [ADR-0017 (append-only
+hot indexes)](adr/0017-correctness-first-append-only-hot-indexes.md),
+[ADR-0022 (merged live-postings hot index)](adr/0022-merged-live-postings-hot-index.md),
+[ADR-0019 (cached balance projection)](adr/0019-cached-balance-projection.md),
+and [ADR-0008 (conformance-tested storage)](adr/0008-conformance-tested-storage.md).
+
+## Entity relationships
+
+Edges are **logical only** (no `FOREIGN KEY` constraint exists); the labels name
+how the ledger relates the rows in Rust.
+
+```mermaid
+erDiagram
+    accounts {
+        BIGINT id PK
+        BIGINT subaccount PK
+        BIGINT version PK
+        INTEGER flags
+        BIGINT book
+        TEXT metadata
+    }
+    account_head {
+        BIGINT id PK
+        BIGINT subaccount PK
+        BIGINT version
+    }
+    postings {
+        TEXT transfer_id PK
+        SMALLINT idx PK
+        BIGINT owner
+        BIGINT subaccount
+        INTEGER asset
+        TEXT value
+    }
+    live_postings {
+        TEXT transfer_id PK
+        SMALLINT idx PK
+        BIGINT owner
+        BIGINT subaccount
+        INTEGER asset
+        TEXT value
+        BIGINT reservation
+    }
+    transfers {
+        TEXT id PK
+        TEXT transfer
+        TEXT receipt
+        BIGINT created_at
+        BIGINT book
+    }
+    transfer_accounts {
+        TEXT transfer_id PK
+        BIGINT account_id PK
+        BIGINT subaccount PK
+    }
+    books {
+        BIGINT id PK
+        TEXT name
+        TEXT data
+    }
+    balance_projection {
+        BIGINT id PK
+        BIGINT account
+        BIGINT subaccount
+        INTEGER asset
+        TEXT balance
+        BIGINT watermark
+    }
+    sagas {
+        BIGINT id PK
+        TEXT data
+    }
+    events {
+        BIGINT seq PK
+        BIGINT timestamp
+        TEXT kind
+        TEXT data
+        TEXT dedup_key UK
+    }
+    migrations {
+        TEXT name PK
+    }
+
+    accounts        ||--|| account_head       : "head -> current version"
+    accounts        }o--|| books              : "book id"
+    transfers       ||--o{ transfer_accounts  : "id = transfer_id"
+    accounts        ||--o{ transfer_accounts  : "account_id + subaccount"
+    transfers       ||--o{ postings           : "id = transfer_id"
+    postings        ||--o| live_postings      : "live index copy"
+    accounts        ||--o{ balance_projection : "account + subaccount"
+```
+
+(The `_migrations` table is shown as `migrations`; Mermaid entity names cannot
+start with an underscore.)
+
+## Accounts
+
+Append-only, versioned accounts with a head pointer. Owned by `account.rs`
+(`AccountStore`). See [ADR-0012 (subaccounts)](adr/0012-subaccounts.md) and
+[ADR-0020 (account transition recovery)](adr/0020-account-transition-recovery.md).
+
+### `accounts`
+
+Every account version is an immutable row; a new version is appended, never
+updated in place. `metadata` is JSON.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Base account id. |
+| `subaccount` | `BIGINT` | PK | Subaccount code (`0` = base). |
+| `version` | `BIGINT` | PK | Monotonic version; the chain is gap-free. |
+| `flags` | `INTEGER` | | `AccountFlags` bitfield (frozen/closed/inflight, `DEBIT_MUST_NOT_EXCEED_CREDIT`). |
+| `book` | `BIGINT` | | Owning book id. |
+| `metadata` | `TEXT` | | JSON key/value metadata. |
+
+- **Primary key**: `(id, subaccount, version)`.
+
+### `account_head`
+
+One row per account pointing at its current version, so a lookup is a single
+indexed join instead of scanning the version chain. Maintained by delete+insert,
+never `UPDATE`.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Base account id. |
+| `subaccount` | `BIGINT` | PK | Subaccount code. |
+| `version` | `BIGINT` | | The current version in `accounts`. |
+
+- **Primary key**: `(id, subaccount)`.
+
+## Postings
+
+A posting is a signed amount of one asset owned by one (sub)account, identified
+by `(transfer_id, idx)`. The immutable `postings` record is the historical source
+of truth; one `live_postings` hot table carries a full row copy of the live set
+(spendable + reserved), so spendable reads never merge back to history. Lifecycle
+state is *derived* from `live_postings` membership plus its `reservation` column:
+present with `reservation` NULL = Active, present with a reservation = Reserved,
+in `postings` only = Spent, absent = Missing. Owned by `posting.rs`
+(`PostingStore`). See
+[ADR-0016 (immutable postings + index tables)](adr/0016-immutable-postings-index-tables.md),
+[ADR-0017 (full-row hot copies)](adr/0017-correctness-first-append-only-hot-indexes.md),
+[ADR-0022 (merged hot index)](adr/0022-merged-live-postings-hot-index.md),
+[ADR-0006 (reservation protocol)](adr/0006-reservation-protocol-posting-lifecycle.md).
+
+**Why one hot table with full-row copies?** The index originally held only ids in
+two tables ([ADR-0016](adr/0016-immutable-postings-index-tables.md));
+[ADR-0017](adr/0017-correctness-first-append-only-hot-indexes.md) switched to
+full-row copies, and [ADR-0022](adr/0022-merged-live-postings-hot-index.md) merged
+the active and reserved tables into one `live_postings` (a nullable `reservation`
+replaces the two-table split). The hot read is "what can this account spend in
+this asset" (`get_postings_by_account`, `query_postings`, and the balance sum):
+carrying the data columns lets `live_postings` hold `idx_live_owner(owner,
+subaccount, asset)`, so a live read is one index scan on a small table with no
+join back to history and no `UNION` (the index *is* the table). The duplication
+is safe because the copied columns are immutable: `postings` rows never change and
+reserve/release only flip the `reservation` column, so a copy can never drift from
+its value row. And it is bounded and disposable: only the live set is duplicated
+(spent postings live in `postings` alone), and `live_postings` is rebuildable from
+`postings` plus the saga write-ahead records, so a corrupt hot table is a
+drop-and-rebuild, not data loss.
+
+### `postings`
+
+The immutable record. A row here that is absent from `live_postings` is Spent.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `transfer_id` | `TEXT` | PK | Creating transfer's id (hex). |
+| `idx` | `SMALLINT` | PK | Position within that transfer. |
+| `owner` | `BIGINT` | | Owning base account id. |
+| `subaccount` | `BIGINT` | | Owning subaccount code. |
+| `asset` | `INTEGER` | | Asset id. |
+| `value` | `TEXT` | | Signed `Cent` as a decimal string. |
+
+- **Primary key**: `(transfer_id, idx)`.
+- **Index**: `idx_postings_owner (owner, subaccount, asset)`.
+
+### `live_postings`
+
+The live-set hot copy: the six data columns plus a nullable `reservation`. A
+posting is here while it is spendable or reserved; `reservation IS NULL` = Active,
+a set `reservation` = Reserved by that saga. Reserve/release flip the column
+(`UPDATE`), consume deletes the row (→ Spent). Rebuildable from `postings` + the
+saga records.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `transfer_id` | `TEXT` | PK | Posting id (hex). |
+| `idx` | `SMALLINT` | PK | Position within the transfer. |
+| `owner` | `BIGINT` | | Owning base account id. |
+| `subaccount` | `BIGINT` | | Owning subaccount code. |
+| `asset` | `INTEGER` | | Asset id. |
+| `value` | `TEXT` | | Signed `Cent` as a decimal string. |
+| `reservation` | `BIGINT` | | Nullable: NULL = Active, set = the `ReservationId` holding this posting (Reserved). |
+
+- **Primary key**: `(transfer_id, idx)`.
+- **Index**: `idx_live_owner (owner, subaccount, asset)`.
+
+## Transfers
+
+Committed envelope records and the account index that finds them. Owned by
+`transfer.rs` (`TransferStore`).
+
+### `transfers`
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `TEXT` | PK | Content-addressed envelope id (hex). |
+| `transfer` | `TEXT` | | The `Envelope` as JSON. |
+| `receipt` | `TEXT` | | The `Receipt` as JSON. |
+| `created_at` | `BIGINT` | | Unix millis when stored (default `0`). |
+| `book` | `BIGINT` | | Owning book id (default `0`). |
+
+- **Primary key**: `id`.
+- **Indexes**: `idx_transfers_created_at (created_at)`, `idx_transfers_book (book)`.
+
+### `transfer_accounts`
+
+The account -> transfer index; the caller supplies the involved set, the store
+does no computation.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `transfer_id` | `TEXT` | PK | The transfer id (hex). |
+| `account_id` | `BIGINT` | PK | An involved base account id. |
+| `subaccount` | `BIGINT` | PK | The involved subaccount code. |
+
+- **Primary key**: `(transfer_id, account_id, subaccount)`.
+- **Index**: `idx_xfer_acct (account_id, subaccount)`.
+
+## Ledger plumbing
+
+### `sagas`
+
+Write-ahead saga records for crash recovery. Owned by `saga.rs` (`SagaStore`).
+See [ADR-0002 (saga commit pipeline)](adr/0002-saga-commit-pipeline.md).
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Saga id (the reservation id). |
+| `data` | `TEXT` | | The encoded `PendingSaga` record (hex). |
+
+- **Primary key**: `id`.
+
+### `events`
+
+The append-only ledger event log, idempotent on the dedup key. Owned by
+`event.rs` (`EventStore`). See [ADR-0010 (event stream vs transfer
+log)](adr/0010-event-stream-vs-transfer-log.md).
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `seq` | `BIGINT` | PK | Monotonic sequence number. |
+| `timestamp` | `BIGINT` | | Unix millis of the event. |
+| `kind` | `TEXT` | | Event kind tag (JSON). |
+| `data` | `TEXT` | | The full `LedgerEvent` as JSON. |
+| `dedup_key` | `TEXT` | UNIQUE | Stable key; a replayed event returns the existing `seq`. |
+
+- **Primary key**: `seq`. **Unique**: `dedup_key`.
+
+### `books`
+
+Book definitions (asset/account/flag policy) as JSON. Owned by `book.rs`
+(`BookStore`). See [ADR-0013 (journaling model)](adr/0013-journaling-model.md).
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Book id. |
+| `name` | `TEXT` | | Human-readable name. |
+| `data` | `TEXT` | | The `Book` (with its `BookPolicy`) as JSON. |
+
+- **Primary key**: `id`.
+
+### `balance_projection`
+
+Append-only balance cache points: each row snapshots one `(account, subaccount,
+asset)` balance at a commit-time watermark. Rows are only inserted; a read picks
+the highest-id row at or before a watermark. A derived, rebuildable accelerator,
+never authoritative. Owned by `projection.rs` (`BalanceProjectionStore`). See
+[ADR-0019](adr/0019-cached-balance-projection.md).
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Rust-minted monotonic id (tie-breaker for equal watermarks). |
+| `account` | `BIGINT` | | Base account id. |
+| `subaccount` | `BIGINT` | | Subaccount code. |
+| `asset` | `INTEGER` | | Asset id. |
+| `balance` | `TEXT` | | The cached `Cent` as a decimal string. |
+| `watermark` | `BIGINT` | | Commit-time watermark (unix millis) this snapshot covers. |
+
+- **Primary key**: `id`.
+- **Index**: `idx_balance_projection_closest (account, subaccount, asset, watermark, id)`.
+
+### `_migrations`
+
+The applied-migration ledger. Created in `migrate.rs` (not a `.sql` file); a
+migration whose `name` is present is skipped, making `migrate()` idempotent.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `name` | `TEXT` | PK | The migration name (e.g. `007_balance_projection`). |
+
+- **Primary key**: `name`.