lib.rs 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432
  1. //! SQL-backed Store implementation for SQLite and PostgreSQL.
  2. //!
  3. //! Uses `sqlx::Any` for database-agnostic queries. Enable features
  4. //! `sqlite` or `postgres` to select the backend.
  5. //!
  6. //! ```text
  7. //! let pool = sqlx::any::Pool<Any>Options::new()
  8. //! .connect("sqlite::memory:").await?;
  9. //! let store = SqlStore::new(pool);
  10. //! store.migrate().await?;
  11. //! ```
  12. use std::collections::{HashMap, HashSet};
  13. use std::str::FromStr;
  14. use std::sync::atomic::{AtomicU8, Ordering};
  15. use async_trait::async_trait;
  16. use sqlx::{Any, Pool, Row};
  17. use kuatia_storage::error::StoreError;
  18. use kuatia_storage::events::{EventStore, LedgerEvent};
  19. use kuatia_storage::store::*;
  20. use kuatia_types::*;
  21. // Cached backend kind for `SqlStore::backend`.
  22. const BACKEND_UNKNOWN: u8 = 0;
  23. const BACKEND_POSTGRES: u8 = 1;
  24. const BACKEND_SQLITE: u8 = 2;
  25. /// Row-locking clause appended to a `SELECT` on backends that support it
  26. /// (PostgreSQL). SQLite has no `FOR UPDATE` and serializes writers itself, so it
  27. /// gets an empty clause.
  28. const FOR_UPDATE: &str = " FOR UPDATE";
  29. /// SQL-backed [`Store`] implementation.
  30. pub struct SqlStore {
  31. pool: Pool<Any>,
  32. autoid: kuatia_types::autoid::AutoId,
  33. /// Detected backend kind (lazily probed): one of `BACKEND_*`.
  34. backend: AtomicU8,
  35. }
  36. impl SqlStore {
  37. /// Create a new SQL store wrapping an existing connection pool.
  38. pub fn new(pool: Pool<Any>) -> Self {
  39. Self {
  40. pool,
  41. autoid: kuatia_types::autoid::AutoId::new(),
  42. backend: AtomicU8::new(BACKEND_UNKNOWN),
  43. }
  44. }
  45. /// Whether the backend is PostgreSQL. Probed once and cached: `SELECT
  46. /// sqlite_version()` succeeds only on SQLite, so a failure means Postgres.
  47. async fn is_postgres(&self) -> Result<bool, StoreError> {
  48. match self.backend.load(Ordering::Relaxed) {
  49. BACKEND_POSTGRES => return Ok(true),
  50. BACKEND_SQLITE => return Ok(false),
  51. _ => {}
  52. }
  53. let is_sqlite = sqlx::query("SELECT sqlite_version()")
  54. .fetch_optional(&self.pool)
  55. .await
  56. .is_ok();
  57. self.backend.store(
  58. if is_sqlite {
  59. BACKEND_SQLITE
  60. } else {
  61. BACKEND_POSTGRES
  62. },
  63. Ordering::Relaxed,
  64. );
  65. Ok(!is_sqlite)
  66. }
  67. /// The row-locking clause for the current backend: [`FOR_UPDATE`] on
  68. /// Postgres, empty on SQLite.
  69. async fn lock_clause(&self) -> Result<&'static str, StoreError> {
  70. Ok(if self.is_postgres().await? {
  71. FOR_UPDATE
  72. } else {
  73. ""
  74. })
  75. }
  76. /// Run database migrations. Idempotent: a `_migrations` ledger records what
  77. /// has been applied, so re-running is a no-op. Every column is a text type,
  78. /// so the store holds no opaque binary and the DDL is identical for both
  79. /// backends. Content-addressed ids and opaque saga bytes are stored as hex
  80. /// `TEXT`, and JSON payloads as their `TEXT` serialization, keeping every
  81. /// row legible for auditing.
  82. pub async fn migrate(&self) -> Result<(), StoreError> {
  83. sqlx::query("CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY)")
  84. .execute(&self.pool)
  85. .await
  86. .map_err(|e| StoreError::Internal(e.to_string()))?;
  87. let migrations: &[(&str, &str)] = &[
  88. ("001_init", include_str!("migrations/001_init.sql")),
  89. (
  90. "002_subaccounts",
  91. include_str!("migrations/002_subaccounts.sql"),
  92. ),
  93. (
  94. "003_drop_user_data",
  95. include_str!("migrations/003_drop_user_data.sql"),
  96. ),
  97. (
  98. "004_index_tables",
  99. include_str!("migrations/004_index_tables.sql"),
  100. ),
  101. (
  102. "005_account_head",
  103. include_str!("migrations/005_account_head.sql"),
  104. ),
  105. ];
  106. for (name, sql) in migrations {
  107. let applied = sqlx::query("SELECT 1 FROM _migrations WHERE name = $1")
  108. .bind(*name)
  109. .fetch_optional(&self.pool)
  110. .await
  111. .map_err(|e| StoreError::Internal(e.to_string()))?;
  112. if applied.is_some() {
  113. continue;
  114. }
  115. // Apply every statement and record the migration in one transaction,
  116. // so a crash mid-migration rolls back cleanly and the migration is
  117. // retried as a whole. Migration 004 drops and rebuilds `postings`;
  118. // without the transaction a partial apply would leave the schema in a
  119. // state the migration cannot be re-run against. Both SQLite and
  120. // PostgreSQL support transactional DDL.
  121. let mut tx = self
  122. .pool
  123. .begin()
  124. .await
  125. .map_err(|e| StoreError::Internal(e.to_string()))?;
  126. for statement in sql.split(';') {
  127. let trimmed = statement.trim();
  128. if !trimmed.is_empty() {
  129. sqlx::query(trimmed)
  130. .execute(&mut *tx)
  131. .await
  132. .map_err(|e| StoreError::Internal(e.to_string()))?;
  133. }
  134. }
  135. sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
  136. .bind(*name)
  137. .execute(&mut *tx)
  138. .await
  139. .map_err(|e| StoreError::Internal(e.to_string()))?;
  140. tx.commit()
  141. .await
  142. .map_err(|e| StoreError::Internal(e.to_string()))?;
  143. }
  144. Ok(())
  145. }
  146. }
  147. // ---------------------------------------------------------------------------
  148. // Serialization helpers
  149. // ---------------------------------------------------------------------------
  150. fn serialize_policy(policy: &AccountPolicy) -> Result<String, StoreError> {
  151. serde_json::to_string(policy)
  152. .map_err(|e| StoreError::Internal(format!("policy serialization: {e}")))
  153. }
  154. fn deserialize_policy(s: &str) -> Result<AccountPolicy, StoreError> {
  155. serde_json::from_str(s).map_err(|e| StoreError::Internal(format!("bad policy: {e}")))
  156. }
  157. /// Serialize a value to a JSON string. Payload columns store JSON as `TEXT` so
  158. /// the database is directly readable for auditing; the ledger never queries
  159. /// into the JSON, so no binary or indexed representation is needed.
  160. fn serialize_json<T: serde::Serialize>(val: &T) -> Result<String, StoreError> {
  161. serde_json::to_string(val).map_err(|e| StoreError::Internal(format!("json serialization: {e}")))
  162. }
  163. fn deserialize_json<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, StoreError> {
  164. serde_json::from_str(s).map_err(|e| StoreError::Internal(format!("bad json: {e}")))
  165. }
  166. /// Lower-case hex encoding. Binary identifiers (content-addressed hashes) and
  167. /// opaque saga bytes are stored as hex `TEXT` so a row is legible in any SQL
  168. /// client and matches the hex form used in logs and `Debug` output.
  169. fn to_hex(bytes: &[u8]) -> String {
  170. const HEX: &[u8; 16] = b"0123456789abcdef";
  171. let mut s = String::with_capacity(bytes.len() * 2);
  172. for &b in bytes {
  173. s.push(HEX[(b >> 4) as usize] as char);
  174. s.push(HEX[(b & 0x0f) as usize] as char);
  175. }
  176. s
  177. }
  178. fn from_hex(s: &str) -> Result<Vec<u8>, StoreError> {
  179. if s.len() % 2 != 0 {
  180. return Err(StoreError::Internal(format!("odd-length hex: {s:?}")));
  181. }
  182. (0..s.len())
  183. .step_by(2)
  184. .map(|i| {
  185. u8::from_str_radix(&s[i..i + 2], 16)
  186. .map_err(|e| StoreError::Internal(format!("bad hex: {e}")))
  187. })
  188. .collect()
  189. }
  190. fn envelope_id_to_hex(id: &EnvelopeId) -> String {
  191. to_hex(&id.0)
  192. }
  193. fn envelope_id_from_hex(s: &str) -> Result<EnvelopeId, StoreError> {
  194. let bytes = from_hex(s)?;
  195. let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
  196. StoreError::Internal(format!("expected 32-byte id, got {} bytes", bytes.len()))
  197. })?;
  198. Ok(EnvelopeId(arr))
  199. }
  200. fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
  201. let id: i64 = row
  202. .try_get("id")
  203. .map_err(|e| StoreError::Internal(e.to_string()))?;
  204. let subaccount: i64 = row
  205. .try_get("subaccount")
  206. .map_err(|e| StoreError::Internal(e.to_string()))?;
  207. let version: i64 = row
  208. .try_get("version")
  209. .map_err(|e| StoreError::Internal(e.to_string()))?;
  210. let policy_str: String = row
  211. .try_get("policy")
  212. .map_err(|e| StoreError::Internal(e.to_string()))?;
  213. let flags_bits: i32 = row
  214. .try_get("flags")
  215. .map_err(|e| StoreError::Internal(e.to_string()))?;
  216. let book: i64 = row
  217. .try_get("book")
  218. .map_err(|e| StoreError::Internal(e.to_string()))?;
  219. let metadata_json: String = row
  220. .try_get("metadata")
  221. .map_err(|e| StoreError::Internal(e.to_string()))?;
  222. Ok(Account {
  223. id: AccountId::with_sub(id, subaccount),
  224. version: version as u64,
  225. policy: deserialize_policy(&policy_str)?,
  226. flags: AccountFlags::from_bits_truncate(flags_bits as u32),
  227. book: BookId::new(book),
  228. metadata: deserialize_json(&metadata_json)?,
  229. })
  230. }
  231. fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
  232. let transfer_id: String = row
  233. .try_get("transfer_id")
  234. .map_err(|e| StoreError::Internal(e.to_string()))?;
  235. let idx: i16 = row
  236. .try_get("idx")
  237. .map_err(|e| StoreError::Internal(e.to_string()))?;
  238. let owner: i64 = row
  239. .try_get("owner")
  240. .map_err(|e| StoreError::Internal(e.to_string()))?;
  241. let subaccount: i64 = row
  242. .try_get("subaccount")
  243. .map_err(|e| StoreError::Internal(e.to_string()))?;
  244. let asset: i32 = row
  245. .try_get("asset")
  246. .map_err(|e| StoreError::Internal(e.to_string()))?;
  247. let value: String = row
  248. .try_get("value")
  249. .map_err(|e| StoreError::Internal(e.to_string()))?;
  250. let value = Cent::from_str(&value).map_err(|e| StoreError::Internal(e.to_string()))?;
  251. Ok(Posting {
  252. id: PostingId {
  253. transfer: envelope_id_from_hex(&transfer_id)?,
  254. index: idx as u16,
  255. },
  256. owner: AccountId::with_sub(owner, subaccount),
  257. asset: AssetId::new(asset as u32),
  258. value,
  259. })
  260. }
  261. /// The FROM source for a posting read of the given derived state. Each index
  262. /// table carries a full row copy, so the live-set reads target the index table
  263. /// directly with no merge back to the immutable `postings` record. `Live` is a
  264. /// `UNION ALL` of the two disjoint live sets (the shared 6 data columns), still
  265. /// with no join to history. Portable across SQLite and PostgreSQL.
  266. fn filter_source(filter: PostingFilter) -> &'static str {
  267. match filter {
  268. PostingFilter::Active => "active_postings",
  269. PostingFilter::Reserved => "reserved_postings",
  270. PostingFilter::All => "postings",
  271. PostingFilter::Live => {
  272. "(SELECT transfer_id, idx, owner, subaccount, asset, value FROM active_postings \
  273. UNION ALL \
  274. SELECT transfer_id, idx, owner, subaccount, asset, value FROM reserved_postings) AS live"
  275. }
  276. }
  277. }
  278. /// Maximum posting ids matched by a single statement. `id_predicate` expands to
  279. /// an `OR` of `n` equality pairs, so the binding constraint is SQLite's
  280. /// expression-tree depth limit (`SQLITE_MAX_EXPR_DEPTH`, default 1000), which a
  281. /// chain of `n` `OR`s reaches at roughly `n` deep. It caps well before the
  282. /// bind-parameter limits (SQLite 32766, PostgreSQL 65535) that `2 * n (+1)`
  283. /// parameters would hit. `500` stays comfortably under the expression-depth
  284. /// limit; callers that pass more ids are chunked, so the id-batch primitives
  285. /// have no practical ceiling on batch size.
  286. const MAX_IDS_PER_QUERY: usize = 500;
  287. /// Build a portable predicate matching a set of posting ids:
  288. /// `(transfer_id = $s AND idx = $s+1) OR (transfer_id = $s+2 AND idx = $s+3) ...`
  289. /// starting at placeholder `$start`. Row-value `IN ((a, b), ...)` is not
  290. /// portable across SQLite and PostgreSQL; an `OR` of equality pairs is. The
  291. /// caller binds each id as `(hex(transfer), idx as i16)` in order, matching the
  292. /// placeholder sequence. `ids` must be non-empty and no longer than
  293. /// [`MAX_IDS_PER_QUERY`]; larger sets are split into chunks by the caller.
  294. fn id_predicate(count: usize, start: u32) -> String {
  295. (0..count)
  296. .map(|i| {
  297. let p = start + (i as u32) * 2;
  298. format!("(transfer_id = ${} AND idx = ${})", p, p + 1)
  299. })
  300. .collect::<Vec<_>>()
  301. .join(" OR ")
  302. }
  303. // ---------------------------------------------------------------------------
  304. // AccountStore
  305. // ---------------------------------------------------------------------------
  306. #[async_trait]
  307. impl AccountStore for SqlStore {
  308. async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError> {
  309. // The head points at the current version, so this is a single indexed
  310. // lookup into the immutable history — no scan of the version chain.
  311. let row = sqlx::query(
  312. "SELECT a.* FROM accounts a \
  313. JOIN account_head h \
  314. ON h.id = a.id AND h.subaccount = a.subaccount AND h.version = a.version \
  315. WHERE h.id = $1 AND h.subaccount = $2",
  316. )
  317. .bind(id.id)
  318. .bind(id.sub)
  319. .fetch_optional(&self.pool)
  320. .await
  321. .map_err(|e| StoreError::Internal(e.to_string()))?
  322. .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
  323. row_to_account(&row)
  324. }
  325. async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError> {
  326. let mut result = Vec::with_capacity(ids.len());
  327. for id in ids {
  328. result.push(self.get_account(id).await?);
  329. }
  330. Ok(result)
  331. }
  332. async fn create_account(&self, account: Account) -> Result<(), StoreError> {
  333. // Pessimistic locking: inside one transaction, lock the account's head
  334. // row with `SELECT ... FOR UPDATE` so a concurrent creator waits. The
  335. // head is the single row per account; its `ON CONFLICT (id, subaccount)
  336. // DO NOTHING` insert is the portable backstop that decides the winner
  337. // (SQLite has no `FOR UPDATE`, and it turns a concurrent double-create
  338. // into a clean affected-row count instead of a unique violation).
  339. let lock = self.lock_clause().await?;
  340. let mut tx = self
  341. .pool
  342. .begin()
  343. .await
  344. .map_err(|e| StoreError::Internal(e.to_string()))?;
  345. let existing = sqlx::query(&format!(
  346. "SELECT 1 FROM account_head WHERE id = $1 AND subaccount = $2 LIMIT 1{lock}"
  347. ))
  348. .bind(account.id.id)
  349. .bind(account.id.sub)
  350. .fetch_optional(&mut *tx)
  351. .await
  352. .map_err(|e| StoreError::Internal(e.to_string()))?;
  353. if existing.is_some() {
  354. return Err(StoreError::AlreadyExists(format!(
  355. "account {:?}",
  356. account.id
  357. )));
  358. }
  359. // Append the immutable first version, then point the head at it.
  360. sqlx::query(
  361. "INSERT INTO accounts (id, subaccount, version, policy, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id, subaccount, version) DO NOTHING"
  362. )
  363. .bind(account.id.id)
  364. .bind(account.id.sub)
  365. .bind(account.version as i64)
  366. .bind(serialize_policy(&account.policy)?)
  367. .bind(account.flags.bits() as i32)
  368. .bind(account.book.0)
  369. .bind(serialize_json(&account.metadata)?)
  370. .execute(&mut *tx)
  371. .await
  372. .map_err(|e| StoreError::Internal(e.to_string()))?;
  373. let res = sqlx::query(
  374. "INSERT INTO account_head (id, subaccount, version) VALUES ($1, $2, $3) ON CONFLICT (id, subaccount) DO NOTHING",
  375. )
  376. .bind(account.id.id)
  377. .bind(account.id.sub)
  378. .bind(account.version as i64)
  379. .execute(&mut *tx)
  380. .await
  381. .map_err(|e| StoreError::Internal(e.to_string()))?;
  382. if res.rows_affected() == 0 {
  383. return Err(StoreError::AlreadyExists(format!(
  384. "account {:?}",
  385. account.id
  386. )));
  387. }
  388. tx.commit()
  389. .await
  390. .map_err(|e| StoreError::Internal(e.to_string()))?;
  391. Ok(())
  392. }
  393. async fn append_account_version(&self, account: Account) -> Result<(), StoreError> {
  394. // Pessimistic locking: inside one transaction, lock the account's head
  395. // row with `SELECT ... FOR UPDATE` so a concurrent appender waits here
  396. // until we commit, then check the version, append the new immutable row,
  397. // and move the head. `ON CONFLICT` is the portable backstop (SQLite has
  398. // no `FOR UPDATE`, and it covers the append phantom-insert a row lock
  399. // does not). The head is maintained by delete + insert, never `UPDATE`,
  400. // so the write path issues only inserts and deletes.
  401. let lock = self.lock_clause().await?;
  402. let mut tx = self
  403. .pool
  404. .begin()
  405. .await
  406. .map_err(|e| StoreError::Internal(e.to_string()))?;
  407. let current = sqlx::query(&format!(
  408. "SELECT version FROM account_head WHERE id = $1 AND subaccount = $2{lock}"
  409. ))
  410. .bind(account.id.id)
  411. .bind(account.id.sub)
  412. .fetch_optional(&mut *tx)
  413. .await
  414. .map_err(|e| StoreError::Internal(e.to_string()))?
  415. .ok_or_else(|| StoreError::NotFound(format!("account {:?}", account.id)))?;
  416. let current_version: i64 = current
  417. .try_get("version")
  418. .map_err(|e| StoreError::Internal(e.to_string()))?;
  419. let expected = current_version
  420. .checked_add(1)
  421. .ok_or_else(|| StoreError::Internal("account version overflow".to_string()))?;
  422. if account.version as i64 != expected {
  423. return Err(StoreError::VersionConflict {
  424. account: account.id,
  425. expected: expected as u64,
  426. actual: account.version,
  427. });
  428. }
  429. let res = sqlx::query(
  430. "INSERT INTO accounts (id, subaccount, version, policy, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id, subaccount, version) DO NOTHING"
  431. )
  432. .bind(account.id.id)
  433. .bind(account.id.sub)
  434. .bind(account.version as i64)
  435. .bind(serialize_policy(&account.policy)?)
  436. .bind(account.flags.bits() as i32)
  437. .bind(account.book.0)
  438. .bind(serialize_json(&account.metadata)?)
  439. .execute(&mut *tx)
  440. .await
  441. .map_err(|e| StoreError::Internal(e.to_string()))?;
  442. if res.rows_affected() == 0 {
  443. return Err(StoreError::VersionConflict {
  444. account: account.id,
  445. expected: expected as u64,
  446. actual: account.version,
  447. });
  448. }
  449. // Move the head to the new version (delete + insert, never update).
  450. sqlx::query("DELETE FROM account_head WHERE id = $1 AND subaccount = $2")
  451. .bind(account.id.id)
  452. .bind(account.id.sub)
  453. .execute(&mut *tx)
  454. .await
  455. .map_err(|e| StoreError::Internal(e.to_string()))?;
  456. sqlx::query("INSERT INTO account_head (id, subaccount, version) VALUES ($1, $2, $3)")
  457. .bind(account.id.id)
  458. .bind(account.id.sub)
  459. .bind(account.version as i64)
  460. .execute(&mut *tx)
  461. .await
  462. .map_err(|e| StoreError::Internal(e.to_string()))?;
  463. tx.commit()
  464. .await
  465. .map_err(|e| StoreError::Internal(e.to_string()))?;
  466. Ok(())
  467. }
  468. async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError> {
  469. let rows = sqlx::query(
  470. "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version ASC",
  471. )
  472. .bind(id.id)
  473. .bind(id.sub)
  474. .fetch_all(&self.pool)
  475. .await
  476. .map_err(|e| StoreError::Internal(e.to_string()))?;
  477. if rows.is_empty() {
  478. return Err(StoreError::NotFound(format!("account {id:?}")));
  479. }
  480. rows.iter().map(row_to_account).collect()
  481. }
  482. async fn list_accounts(&self) -> Result<Vec<Account>, StoreError> {
  483. // One row per account via the head; no read-all-versions + dedup.
  484. let rows = sqlx::query(
  485. "SELECT a.* FROM accounts a \
  486. JOIN account_head h \
  487. ON h.id = a.id AND h.subaccount = a.subaccount AND h.version = a.version",
  488. )
  489. .fetch_all(&self.pool)
  490. .await
  491. .map_err(|e| StoreError::Internal(e.to_string()))?;
  492. rows.iter().map(row_to_account).collect()
  493. }
  494. }
  495. // ---------------------------------------------------------------------------
  496. // PostingStore
  497. // ---------------------------------------------------------------------------
  498. #[async_trait]
  499. impl PostingStore for SqlStore {
  500. async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError> {
  501. if ids.is_empty() {
  502. return Ok(Vec::new());
  503. }
  504. // Set-based query per chunk instead of one probe per id, reusing the
  505. // portable `id_predicate` and binding each id in order as
  506. // `(hex(transfer), idx as i16)`. Chunked so a large batch never exceeds
  507. // the backend's bind-parameter limit (see `MAX_IDS_PER_QUERY`).
  508. let mut found: HashMap<(String, i16), Posting> = HashMap::with_capacity(ids.len());
  509. for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
  510. let sql = format!(
  511. "SELECT * FROM postings WHERE {}",
  512. id_predicate(chunk.len(), 1)
  513. );
  514. let mut q = sqlx::query(&sql);
  515. for id in chunk {
  516. q = q
  517. .bind(envelope_id_to_hex(&id.transfer))
  518. .bind(id.index as i16);
  519. }
  520. let rows = q
  521. .fetch_all(&self.pool)
  522. .await
  523. .map_err(|e| StoreError::Internal(e.to_string()))?;
  524. // Index the fetched postings by the same `(hex, idx)` key that was bound.
  525. for row in &rows {
  526. let posting = row_to_posting(row)?;
  527. let key = (
  528. envelope_id_to_hex(&posting.id.transfer),
  529. posting.id.index as i16,
  530. );
  531. found.insert(key, posting);
  532. }
  533. }
  534. // Return in input order, erroring on the first id absent from the batch
  535. // (matching the per-id lookup's `NotFound` semantics).
  536. let mut result = Vec::with_capacity(ids.len());
  537. for id in ids {
  538. let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
  539. let posting = found
  540. .get(&key)
  541. .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
  542. result.push(posting.clone());
  543. }
  544. Ok(result)
  545. }
  546. async fn get_postings_by_account(
  547. &self,
  548. id: i64,
  549. sub: Option<i64>,
  550. asset: Option<&AssetId>,
  551. filter: PostingFilter,
  552. ) -> Result<Vec<Posting>, StoreError> {
  553. // Build the predicate dynamically: `sub == None` spans every subaccount
  554. // of `id`, `Some(s)` restricts to one. The subaccount is compared only
  555. // for equality, never as a magnitude. The derived-state filter selects
  556. // which table (index copy or immutable record) to read from directly.
  557. let mut sql = format!("SELECT * FROM {} WHERE owner = $1", filter_source(filter));
  558. let mut placeholder = 2u32;
  559. if sub.is_some() {
  560. sql.push_str(&format!(" AND subaccount = ${placeholder}"));
  561. placeholder += 1;
  562. }
  563. if asset.is_some() {
  564. sql.push_str(&format!(" AND asset = ${placeholder}"));
  565. }
  566. // Deterministic order by the posting primary key, matching
  567. // `query_postings`, so callers (and pagination built on top) see a
  568. // stable sequence.
  569. sql.push_str(" ORDER BY transfer_id, idx");
  570. let mut q = sqlx::query(&sql).bind(id);
  571. if let Some(s) = sub {
  572. q = q.bind(s);
  573. }
  574. if let Some(a) = asset {
  575. q = q.bind(a.0 as i32);
  576. }
  577. let rows = q
  578. .fetch_all(&self.pool)
  579. .await
  580. .map_err(|e| StoreError::Internal(e.to_string()))?;
  581. rows.iter().map(row_to_posting).collect()
  582. }
  583. async fn get_posting_states(&self, ids: &[PostingId]) -> Result<Vec<PostingState>, StoreError> {
  584. if ids.is_empty() {
  585. return Ok(Vec::new());
  586. }
  587. // One set-based query per state table instead of up to three probes per
  588. // id, reusing the portable `id_predicate` (an OR of equality pairs;
  589. // row-value `IN` is not portable across SQLite and PostgreSQL) and
  590. // binding every id in order as `(hex(transfer), idx as i16)`. Chunked so
  591. // a large batch never exceeds the bind-parameter limit.
  592. // Key membership by the same `(hex, idx)` values that were bound, so the
  593. // per-id lookup below matches without decoding transfer ids back.
  594. let row_key = |row: &sqlx::any::AnyRow| -> Result<(String, i16), StoreError> {
  595. let transfer_id: String = row
  596. .try_get("transfer_id")
  597. .map_err(|e| StoreError::Internal(e.to_string()))?;
  598. let idx: i16 = row
  599. .try_get("idx")
  600. .map_err(|e| StoreError::Internal(e.to_string()))?;
  601. Ok((transfer_id, idx))
  602. };
  603. let mut active: HashSet<(String, i16)> = HashSet::new();
  604. let mut reserved: HashMap<(String, i16), i64> = HashMap::new();
  605. let mut spent: HashSet<(String, i16)> = HashSet::new();
  606. for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
  607. let predicate = id_predicate(chunk.len(), 1);
  608. let active_sql =
  609. format!("SELECT transfer_id, idx FROM active_postings WHERE {predicate}");
  610. let mut active_q = sqlx::query(&active_sql);
  611. for id in chunk {
  612. active_q = active_q
  613. .bind(envelope_id_to_hex(&id.transfer))
  614. .bind(id.index as i16);
  615. }
  616. let active_rows = active_q
  617. .fetch_all(&self.pool)
  618. .await
  619. .map_err(|e| StoreError::Internal(e.to_string()))?;
  620. for row in &active_rows {
  621. active.insert(row_key(row)?);
  622. }
  623. let reserved_sql = format!(
  624. "SELECT transfer_id, idx, reservation FROM reserved_postings WHERE {predicate}"
  625. );
  626. let mut reserved_q = sqlx::query(&reserved_sql);
  627. for id in chunk {
  628. reserved_q = reserved_q
  629. .bind(envelope_id_to_hex(&id.transfer))
  630. .bind(id.index as i16);
  631. }
  632. let reserved_rows = reserved_q
  633. .fetch_all(&self.pool)
  634. .await
  635. .map_err(|e| StoreError::Internal(e.to_string()))?;
  636. for row in &reserved_rows {
  637. let rid: i64 = row
  638. .try_get("reservation")
  639. .map_err(|e| StoreError::Internal(e.to_string()))?;
  640. reserved.insert(row_key(row)?, rid);
  641. }
  642. let spent_sql = format!("SELECT transfer_id, idx FROM postings WHERE {predicate}");
  643. let mut spent_q = sqlx::query(&spent_sql);
  644. for id in chunk {
  645. spent_q = spent_q
  646. .bind(envelope_id_to_hex(&id.transfer))
  647. .bind(id.index as i16);
  648. }
  649. let spent_rows = spent_q
  650. .fetch_all(&self.pool)
  651. .await
  652. .map_err(|e| StoreError::Internal(e.to_string()))?;
  653. for row in &spent_rows {
  654. spent.insert(row_key(row)?);
  655. }
  656. }
  657. // Reconstruct each id's state in input order, preserving the active >
  658. // reserved > spent > missing precedence of the original probes.
  659. let mut out = Vec::with_capacity(ids.len());
  660. for id in ids {
  661. let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
  662. out.push(if active.contains(&key) {
  663. PostingState::Active
  664. } else if let Some(rid) = reserved.get(&key) {
  665. PostingState::Reserved(ReservationId::new(*rid))
  666. } else if spent.contains(&key) {
  667. PostingState::Spent
  668. } else {
  669. PostingState::Missing
  670. });
  671. }
  672. Ok(out)
  673. }
  674. async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
  675. let (where_clause, count_clause) = {
  676. let source = filter_source(query.filter);
  677. let mut w = String::from("WHERE owner = $1");
  678. let mut idx = 2u32;
  679. if query.sub.is_some() {
  680. w.push_str(&format!(" AND subaccount = ${idx}"));
  681. idx += 1;
  682. }
  683. if query.asset.is_some() {
  684. w.push_str(&format!(" AND asset = ${idx}"));
  685. }
  686. let c = format!("SELECT COUNT(*) as cnt FROM {source} {w}");
  687. let limit = query.limit.unwrap_or(u32::MAX);
  688. let offset = query.offset.unwrap_or(0);
  689. // Order by the posting primary key so pagination is deterministic:
  690. // without it LIMIT/OFFSET could skip or repeat rows across pages,
  691. // especially for `Live`, whose source is a `UNION ALL` with no
  692. // inherent order.
  693. w.push_str(&format!(
  694. " ORDER BY transfer_id, idx LIMIT {limit} OFFSET {offset}"
  695. ));
  696. (format!("SELECT * FROM {source} {w}"), c)
  697. };
  698. // Build count query
  699. let mut count_q = sqlx::query(&count_clause).bind(query.account);
  700. if let Some(s) = query.sub {
  701. count_q = count_q.bind(s);
  702. }
  703. if let Some(ref a) = query.asset {
  704. count_q = count_q.bind(a.0 as i32);
  705. }
  706. let count_row = count_q
  707. .fetch_one(&self.pool)
  708. .await
  709. .map_err(|e| StoreError::Internal(e.to_string()))?;
  710. let total: i64 = count_row
  711. .try_get("cnt")
  712. .map_err(|e| StoreError::Internal(e.to_string()))?;
  713. // Build data query
  714. let mut data_q = sqlx::query(&where_clause).bind(query.account);
  715. if let Some(s) = query.sub {
  716. data_q = data_q.bind(s);
  717. }
  718. if let Some(ref a) = query.asset {
  719. data_q = data_q.bind(a.0 as i32);
  720. }
  721. let rows = data_q
  722. .fetch_all(&self.pool)
  723. .await
  724. .map_err(|e| StoreError::Internal(e.to_string()))?;
  725. let items: Vec<Posting> = rows.iter().map(row_to_posting).collect::<Result<_, _>>()?;
  726. Ok(Page {
  727. items,
  728. total: total as u64,
  729. })
  730. }
  731. async fn reserve_postings(
  732. &self,
  733. ids: &[PostingId],
  734. reservation: ReservationId,
  735. ) -> Result<u64, StoreError> {
  736. // Dumb instruction over the whole id set, in two statements: copy the
  737. // currently-active rows into the reserved index (sourced from
  738. // `active_postings`, so only active ids move), then delete those same
  739. // ids from `active_postings`. The DELETE's affected count is the number
  740. // claimed, and by active/reserved disjointness it equals the INSERT's
  741. // row count. Concurrent reserves serialize on the reserved-index primary
  742. // key, so exactly one wins each contended id.
  743. if ids.is_empty() {
  744. return Ok(0);
  745. }
  746. let mut tx = self
  747. .pool
  748. .begin()
  749. .await
  750. .map_err(|e| StoreError::Internal(e.to_string()))?;
  751. // Chunked so a large id set stays under the bind-parameter limit; all
  752. // chunks share one transaction so the whole claim is atomic.
  753. let mut claimed: u64 = 0;
  754. for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
  755. // Reservation is $1; each id pair follows starting at $2.
  756. let insert_sql = format!(
  757. "INSERT INTO reserved_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) \
  758. SELECT transfer_id, idx, owner, subaccount, asset, value, $1 FROM active_postings WHERE {} \
  759. ON CONFLICT (transfer_id, idx) DO NOTHING",
  760. id_predicate(chunk.len(), 2)
  761. );
  762. let mut insert_q = sqlx::query(&insert_sql).bind(reservation.0);
  763. for id in chunk {
  764. insert_q = insert_q
  765. .bind(envelope_id_to_hex(&id.transfer))
  766. .bind(id.index as i16);
  767. }
  768. insert_q
  769. .execute(&mut *tx)
  770. .await
  771. .map_err(|e| StoreError::Internal(e.to_string()))?;
  772. let delete_sql = format!(
  773. "DELETE FROM active_postings WHERE {}",
  774. id_predicate(chunk.len(), 1)
  775. );
  776. let mut delete_q = sqlx::query(&delete_sql);
  777. for id in chunk {
  778. delete_q = delete_q
  779. .bind(envelope_id_to_hex(&id.transfer))
  780. .bind(id.index as i16);
  781. }
  782. let del = delete_q
  783. .execute(&mut *tx)
  784. .await
  785. .map_err(|e| StoreError::Internal(e.to_string()))?;
  786. claimed += del.rows_affected();
  787. }
  788. tx.commit()
  789. .await
  790. .map_err(|e| StoreError::Internal(e.to_string()))?;
  791. Ok(claimed)
  792. }
  793. async fn release_postings(
  794. &self,
  795. ids: &[PostingId],
  796. reservation: ReservationId,
  797. ) -> Result<u64, StoreError> {
  798. // Dumb instruction over the whole id set: copy the rows reserved by
  799. // `reservation` back into the active index, then delete them from the
  800. // reserved index. The DELETE's affected count is the number released; an
  801. // id already active or reserved by another saga does not match.
  802. if ids.is_empty() {
  803. return Ok(0);
  804. }
  805. let mut tx = self
  806. .pool
  807. .begin()
  808. .await
  809. .map_err(|e| StoreError::Internal(e.to_string()))?;
  810. // Chunked so a large id set stays under the bind-parameter limit; all
  811. // chunks share one transaction.
  812. let mut released: u64 = 0;
  813. for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
  814. // Reservation is $1; each id pair follows starting at $2.
  815. let insert_sql = format!(
  816. "INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) \
  817. SELECT transfer_id, idx, owner, subaccount, asset, value FROM reserved_postings \
  818. WHERE ({}) AND reservation = $1 ON CONFLICT (transfer_id, idx) DO NOTHING",
  819. id_predicate(chunk.len(), 2)
  820. );
  821. let mut insert_q = sqlx::query(&insert_sql).bind(reservation.0);
  822. for id in chunk {
  823. insert_q = insert_q
  824. .bind(envelope_id_to_hex(&id.transfer))
  825. .bind(id.index as i16);
  826. }
  827. insert_q
  828. .execute(&mut *tx)
  829. .await
  830. .map_err(|e| StoreError::Internal(e.to_string()))?;
  831. let delete_sql = format!(
  832. "DELETE FROM reserved_postings WHERE ({}) AND reservation = $1",
  833. id_predicate(chunk.len(), 2)
  834. );
  835. let mut delete_q = sqlx::query(&delete_sql).bind(reservation.0);
  836. for id in chunk {
  837. delete_q = delete_q
  838. .bind(envelope_id_to_hex(&id.transfer))
  839. .bind(id.index as i16);
  840. }
  841. let del = delete_q
  842. .execute(&mut *tx)
  843. .await
  844. .map_err(|e| StoreError::Internal(e.to_string()))?;
  845. released += del.rows_affected();
  846. }
  847. tx.commit()
  848. .await
  849. .map_err(|e| StoreError::Internal(e.to_string()))?;
  850. Ok(released)
  851. }
  852. async fn deactivate_postings(
  853. &self,
  854. ids: &[PostingId],
  855. reservation: Option<ReservationId>,
  856. ) -> Result<u64, StoreError> {
  857. // Dumb instruction over the whole id set: a DELETE removes the ids from
  858. // an index so they become spent (present only in the immutable table).
  859. // `rows_affected` is the count; the caller interprets a shortfall.
  860. // Chunked under one transaction so a large id set stays within the
  861. // bind-parameter limit while the removal stays atomic.
  862. if ids.is_empty() {
  863. return Ok(0);
  864. }
  865. let mut tx = self
  866. .pool
  867. .begin()
  868. .await
  869. .map_err(|e| StoreError::Internal(e.to_string()))?;
  870. let mut removed: u64 = 0;
  871. for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
  872. let (sql, rid) = match reservation {
  873. // Raw path: remove from the active index.
  874. None => (
  875. format!(
  876. "DELETE FROM active_postings WHERE {}",
  877. id_predicate(chunk.len(), 1)
  878. ),
  879. None,
  880. ),
  881. // Saga path: remove only the rows reserved by `rid`.
  882. Some(rid) => (
  883. format!(
  884. "DELETE FROM reserved_postings WHERE ({}) AND reservation = $1",
  885. id_predicate(chunk.len(), 2)
  886. ),
  887. Some(rid),
  888. ),
  889. };
  890. let mut q = sqlx::query(&sql);
  891. if let Some(rid) = rid {
  892. q = q.bind(rid.0);
  893. }
  894. for id in chunk {
  895. q = q
  896. .bind(envelope_id_to_hex(&id.transfer))
  897. .bind(id.index as i16);
  898. }
  899. let res = q
  900. .execute(&mut *tx)
  901. .await
  902. .map_err(|e| StoreError::Internal(e.to_string()))?;
  903. removed += res.rows_affected();
  904. }
  905. tx.commit()
  906. .await
  907. .map_err(|e| StoreError::Internal(e.to_string()))?;
  908. Ok(removed)
  909. }
  910. async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError> {
  911. // Dumb instruction: insert each posting into the immutable table and, only
  912. // when the row was newly inserted, add its id to the active index. Return
  913. // the count of immutable rows inserted. The newness gate stops a replayed
  914. // finalize from re-activating a since-spent posting.
  915. let mut tx = self
  916. .pool
  917. .begin()
  918. .await
  919. .map_err(|e| StoreError::Internal(e.to_string()))?;
  920. let mut inserted: u64 = 0;
  921. for posting in postings {
  922. let hex = envelope_id_to_hex(&posting.id.transfer);
  923. let res = sqlx::query(
  924. "INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING"
  925. )
  926. .bind(hex.clone())
  927. .bind(posting.id.index as i16)
  928. .bind(posting.owner.id)
  929. .bind(posting.owner.sub)
  930. .bind(posting.asset.0 as i32)
  931. .bind(posting.value.to_string())
  932. .execute(&mut *tx)
  933. .await
  934. .map_err(|e| StoreError::Internal(e.to_string()))?;
  935. if res.rows_affected() == 1 {
  936. // Activate a full copy so spendable reads never merge.
  937. sqlx::query(
  938. "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",
  939. )
  940. .bind(hex)
  941. .bind(posting.id.index as i16)
  942. .bind(posting.owner.id)
  943. .bind(posting.owner.sub)
  944. .bind(posting.asset.0 as i32)
  945. .bind(posting.value.to_string())
  946. .execute(&mut *tx)
  947. .await
  948. .map_err(|e| StoreError::Internal(e.to_string()))?;
  949. inserted += 1;
  950. }
  951. }
  952. tx.commit()
  953. .await
  954. .map_err(|e| StoreError::Internal(e.to_string()))?;
  955. Ok(inserted)
  956. }
  957. }
  958. // ---------------------------------------------------------------------------
  959. // TransferStore
  960. // ---------------------------------------------------------------------------
  961. #[async_trait]
  962. impl TransferStore for SqlStore {
  963. async fn get_transfer(&self, id: &EnvelopeId) -> Result<Option<EnvelopeRecord>, StoreError> {
  964. let row = sqlx::query("SELECT transfer, receipt, created_at FROM transfers WHERE id = $1")
  965. .bind(envelope_id_to_hex(id))
  966. .fetch_optional(&self.pool)
  967. .await
  968. .map_err(|e| StoreError::Internal(e.to_string()))?;
  969. match row {
  970. None => Ok(None),
  971. Some(row) => {
  972. let transfer_json: String = row
  973. .try_get("transfer")
  974. .map_err(|e| StoreError::Internal(e.to_string()))?;
  975. let receipt_json: String = row
  976. .try_get("receipt")
  977. .map_err(|e| StoreError::Internal(e.to_string()))?;
  978. let created_at: i64 = row
  979. .try_get("created_at")
  980. .map_err(|e| StoreError::Internal(e.to_string()))?;
  981. Ok(Some(EnvelopeRecord {
  982. envelope: deserialize_json(&transfer_json)?,
  983. receipt: deserialize_json(&receipt_json)?,
  984. created_at,
  985. }))
  986. }
  987. }
  988. }
  989. async fn store_transfer(
  990. &self,
  991. record: EnvelopeRecord,
  992. involved: &[AccountId],
  993. ) -> Result<u64, StoreError> {
  994. let tid = record.receipt.transfer_id;
  995. let tid_hex = envelope_id_to_hex(&tid);
  996. let transfer_json = serialize_json(&record.envelope)?;
  997. let receipt_json = serialize_json(&record.receipt)?;
  998. let mut tx = self
  999. .pool
  1000. .begin()
  1001. .await
  1002. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1003. let res = sqlx::query("INSERT INTO transfers (id, transfer, receipt, created_at, book) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING")
  1004. .bind(&tid_hex)
  1005. .bind(&transfer_json)
  1006. .bind(&receipt_json)
  1007. .bind(record.created_at)
  1008. .bind(record.envelope.book().0)
  1009. .execute(&mut *tx)
  1010. .await
  1011. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1012. let inserted = res.rows_affected();
  1013. // Index every involved account (caller supplies the set; storage does no
  1014. // computation). Idempotent so a replay is harmless.
  1015. for account in involved {
  1016. sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id, subaccount) VALUES ($1, $2, $3) ON CONFLICT (transfer_id, account_id, subaccount) DO NOTHING")
  1017. .bind(&tid_hex)
  1018. .bind(account.id)
  1019. .bind(account.sub)
  1020. .execute(&mut *tx)
  1021. .await
  1022. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1023. }
  1024. tx.commit()
  1025. .await
  1026. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1027. Ok(inserted)
  1028. }
  1029. async fn get_transfers_for_account(
  1030. &self,
  1031. id: i64,
  1032. sub: Option<i64>,
  1033. ) -> Result<Vec<EnvelopeRecord>, StoreError> {
  1034. // `sub == None` spans every subaccount of `id`; `Some(s)` restricts to
  1035. // one. The subaccount is matched only for equality.
  1036. let mut sql = String::from(
  1037. "SELECT t.id, t.transfer, t.receipt, t.created_at FROM transfers t INNER JOIN transfer_accounts ta ON t.id = ta.transfer_id WHERE ta.account_id = $1",
  1038. );
  1039. if sub.is_some() {
  1040. sql.push_str(" AND ta.subaccount = $2");
  1041. }
  1042. sql.push_str(" ORDER BY t.created_at");
  1043. let mut q = sqlx::query(&sql).bind(id);
  1044. if let Some(s) = sub {
  1045. q = q.bind(s);
  1046. }
  1047. let rows = q
  1048. .fetch_all(&self.pool)
  1049. .await
  1050. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1051. let mut result = Vec::with_capacity(rows.len());
  1052. for row in &rows {
  1053. let transfer_json: String = row
  1054. .try_get("transfer")
  1055. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1056. let receipt_json: String = row
  1057. .try_get("receipt")
  1058. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1059. let created_at: i64 = row
  1060. .try_get("created_at")
  1061. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1062. result.push(EnvelopeRecord {
  1063. envelope: deserialize_json(&transfer_json)?,
  1064. receipt: deserialize_json(&receipt_json)?,
  1065. created_at,
  1066. });
  1067. }
  1068. Ok(result)
  1069. }
  1070. async fn query_transfers(
  1071. &self,
  1072. query: &TransferQuery,
  1073. ) -> Result<Page<EnvelopeRecord>, StoreError> {
  1074. // Load base records, using the account join when available.
  1075. let base_records = if let Some(account) = query.account {
  1076. self.get_transfers_for_account(account, query.sub).await?
  1077. } else {
  1078. let rows = sqlx::query(
  1079. "SELECT transfer, receipt, created_at FROM transfers ORDER BY created_at",
  1080. )
  1081. .fetch_all(&self.pool)
  1082. .await
  1083. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1084. let mut records = Vec::with_capacity(rows.len());
  1085. for row in &rows {
  1086. let transfer_json: String = row
  1087. .try_get("transfer")
  1088. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1089. let receipt_json: String = row
  1090. .try_get("receipt")
  1091. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1092. let created_at: i64 = row
  1093. .try_get("created_at")
  1094. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1095. records.push(EnvelopeRecord {
  1096. envelope: deserialize_json(&transfer_json)?,
  1097. receipt: deserialize_json(&receipt_json)?,
  1098. created_at,
  1099. });
  1100. }
  1101. records
  1102. };
  1103. // Filter in memory for remaining conditions.
  1104. let filtered: Vec<EnvelopeRecord> = base_records
  1105. .into_iter()
  1106. .filter(|r| {
  1107. if let Some(from) = query.from_ts
  1108. && r.created_at < from
  1109. {
  1110. return false;
  1111. }
  1112. if let Some(to) = query.to_ts
  1113. && r.created_at >= to
  1114. {
  1115. return false;
  1116. }
  1117. if let Some(book) = query.book
  1118. && r.envelope.book() != book
  1119. {
  1120. return false;
  1121. }
  1122. true
  1123. })
  1124. .collect();
  1125. let total = filtered.len() as u64;
  1126. let offset = query.offset.unwrap_or(0) as usize;
  1127. let limit = query.limit.unwrap_or(u32::MAX) as usize;
  1128. let items = filtered.into_iter().skip(offset).take(limit).collect();
  1129. Ok(Page { items, total })
  1130. }
  1131. }
  1132. // ---------------------------------------------------------------------------
  1133. // SagaStore
  1134. // ---------------------------------------------------------------------------
  1135. #[async_trait]
  1136. impl SagaStore for SqlStore {
  1137. async fn save_saga(&self, id: &i64, data: Vec<u8>) -> Result<(), StoreError> {
  1138. sqlx::query(
  1139. "INSERT INTO sagas (id, data) VALUES ($1, $2) \
  1140. ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data",
  1141. )
  1142. .bind(*id)
  1143. .bind(to_hex(&data))
  1144. .execute(&self.pool)
  1145. .await
  1146. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1147. Ok(())
  1148. }
  1149. async fn list_pending_sagas(&self) -> Result<Vec<(i64, Vec<u8>)>, StoreError> {
  1150. let rows = sqlx::query("SELECT id, data FROM sagas")
  1151. .fetch_all(&self.pool)
  1152. .await
  1153. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1154. let mut result = Vec::with_capacity(rows.len());
  1155. for row in &rows {
  1156. let id: i64 = row
  1157. .try_get("id")
  1158. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1159. let data_hex: String = row
  1160. .try_get("data")
  1161. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1162. result.push((id, from_hex(&data_hex)?));
  1163. }
  1164. Ok(result)
  1165. }
  1166. async fn delete_saga(&self, id: &i64) -> Result<(), StoreError> {
  1167. sqlx::query("DELETE FROM sagas WHERE id = $1")
  1168. .bind(*id)
  1169. .execute(&self.pool)
  1170. .await
  1171. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1172. Ok(())
  1173. }
  1174. }
  1175. // ---------------------------------------------------------------------------
  1176. // EventStore
  1177. // ---------------------------------------------------------------------------
  1178. #[async_trait]
  1179. impl EventStore for SqlStore {
  1180. async fn append_event(&self, event: &LedgerEvent) -> Result<u64, StoreError> {
  1181. let kind_str =
  1182. serde_json::to_string(&event.kind).map_err(|e| StoreError::Internal(e.to_string()))?;
  1183. let data = serialize_json(event)?;
  1184. let seq = self.autoid.next() as u64;
  1185. // Idempotent on the dedup key: a replayed transfer event conflicts on
  1186. // `dedup_key` and returns the existing seq instead of a duplicate row.
  1187. match kuatia_storage::events::event_dedup_key(&event.kind) {
  1188. Some(eid) => {
  1189. let dedup_hex = envelope_id_to_hex(&eid);
  1190. let res = sqlx::query("INSERT INTO events (seq, timestamp, kind, data, dedup_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (dedup_key) DO NOTHING")
  1191. .bind(seq as i64)
  1192. .bind(event.timestamp)
  1193. .bind(&kind_str)
  1194. .bind(&data)
  1195. .bind(&dedup_hex)
  1196. .execute(&self.pool)
  1197. .await
  1198. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1199. if res.rows_affected() == 0 {
  1200. let row = sqlx::query("SELECT seq FROM events WHERE dedup_key = $1")
  1201. .bind(&dedup_hex)
  1202. .fetch_one(&self.pool)
  1203. .await
  1204. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1205. let existing: i64 = row
  1206. .try_get("seq")
  1207. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1208. return Ok(existing as u64);
  1209. }
  1210. Ok(seq)
  1211. }
  1212. None => {
  1213. sqlx::query(
  1214. "INSERT INTO events (seq, timestamp, kind, data) VALUES ($1, $2, $3, $4)",
  1215. )
  1216. .bind(seq as i64)
  1217. .bind(event.timestamp)
  1218. .bind(&kind_str)
  1219. .bind(&data)
  1220. .execute(&self.pool)
  1221. .await
  1222. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1223. Ok(seq)
  1224. }
  1225. }
  1226. }
  1227. async fn get_events_since(
  1228. &self,
  1229. after_seq: u64,
  1230. limit: u32,
  1231. ) -> Result<Vec<LedgerEvent>, StoreError> {
  1232. let rows = sqlx::query("SELECT seq, data FROM events WHERE seq > $1 ORDER BY seq LIMIT $2")
  1233. .bind(after_seq as i64)
  1234. .bind(limit as i32)
  1235. .fetch_all(&self.pool)
  1236. .await
  1237. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1238. let mut events = Vec::with_capacity(rows.len());
  1239. for row in &rows {
  1240. let seq: i64 = row
  1241. .try_get("seq")
  1242. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1243. let data_json: String = row
  1244. .try_get("data")
  1245. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1246. let mut event: LedgerEvent = deserialize_json(&data_json)?;
  1247. event.seq = seq as u64;
  1248. events.push(event);
  1249. }
  1250. Ok(events)
  1251. }
  1252. }
  1253. // ---------------------------------------------------------------------------
  1254. // BookStore
  1255. // ---------------------------------------------------------------------------
  1256. #[async_trait]
  1257. impl BookStore for SqlStore {
  1258. async fn create_book(&self, book: Book) -> Result<(), StoreError> {
  1259. // Pessimistic locking, same shape as create_account: lock any existing
  1260. // book row with `SELECT ... FOR UPDATE` inside the transaction, then
  1261. // insert with `ON CONFLICT DO NOTHING` as the portable backstop.
  1262. let lock = self.lock_clause().await?;
  1263. let data = serialize_json(&book)?;
  1264. let mut tx = self
  1265. .pool
  1266. .begin()
  1267. .await
  1268. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1269. let existing = sqlx::query(&format!("SELECT 1 FROM books WHERE id = $1 LIMIT 1{lock}"))
  1270. .bind(book.id.0)
  1271. .fetch_optional(&mut *tx)
  1272. .await
  1273. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1274. if existing.is_some() {
  1275. return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
  1276. }
  1277. let res = sqlx::query(
  1278. "INSERT INTO books (id, name, data) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
  1279. )
  1280. .bind(book.id.0)
  1281. .bind(&book.name)
  1282. .bind(&data)
  1283. .execute(&mut *tx)
  1284. .await
  1285. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1286. if res.rows_affected() == 0 {
  1287. return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
  1288. }
  1289. tx.commit()
  1290. .await
  1291. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1292. Ok(())
  1293. }
  1294. async fn get_book(&self, id: &BookId) -> Result<Book, StoreError> {
  1295. let row = sqlx::query("SELECT data FROM books WHERE id = $1")
  1296. .bind(id.0)
  1297. .fetch_optional(&self.pool)
  1298. .await
  1299. .map_err(|e| StoreError::Internal(e.to_string()))?
  1300. .ok_or_else(|| StoreError::NotFound(format!("book {id:?}")))?;
  1301. let data: String = row
  1302. .try_get("data")
  1303. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1304. deserialize_json(&data)
  1305. }
  1306. async fn list_books(&self) -> Result<Vec<Book>, StoreError> {
  1307. let rows = sqlx::query("SELECT data FROM books")
  1308. .fetch_all(&self.pool)
  1309. .await
  1310. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1311. rows.iter()
  1312. .map(|row| {
  1313. let data: String = row
  1314. .try_get("data")
  1315. .map_err(|e| StoreError::Internal(e.to_string()))?;
  1316. deserialize_json(&data)
  1317. })
  1318. .collect()
  1319. }
  1320. }