store.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. //! Storage abstraction separating the pure decision logic from IO.
  2. //!
  3. //! The [`Store`] trait composes focused sub-traits, each a dumb instruction
  4. //! follower: writes apply one update and report an affected-row count (or an I/O
  5. //! error). The saga, not the store, interprets counts and owns idempotency and
  6. //! compensation.
  7. //! - [`AccountStore`] — account CRUD and versioning
  8. //! - [`PostingStore`] — posting reads and lifecycle transitions
  9. //! - [`TransferStore`] — transfer persistence and queries
  10. //! - [`SagaStore`] — saga state for crash recovery
  11. //! - [`EventStore`] — the ledger event log
  12. //! - [`BookStore`] — book persistence
  13. use async_trait::async_trait;
  14. use kuatia_types::{
  15. Account, AccountId, AssetId, Book, BookId, Envelope, EnvelopeId, Posting, PostingId,
  16. PostingStatus, Receipt, ReservationId,
  17. };
  18. use crate::error::StoreError;
  19. use crate::events::EventStore;
  20. /// Pairs a committed transfer with its receipt.
  21. #[derive(Debug, Clone)]
  22. pub struct EnvelopeRecord {
  23. /// The envelope that was committed.
  24. pub envelope: Envelope,
  25. /// The receipt proving commitment.
  26. pub receipt: Receipt,
  27. /// Unix milliseconds when this record was created.
  28. pub created_at: i64,
  29. }
  30. /// Pagination and filtering parameters for posting queries.
  31. #[derive(Debug, Clone)]
  32. pub struct PostingQuery {
  33. /// Filter to postings owned by this base account.
  34. pub account: i64,
  35. /// Restrict to one subaccount; `None` spans every subaccount of `account`.
  36. pub sub: Option<i64>,
  37. /// Filter by asset.
  38. pub asset: Option<AssetId>,
  39. /// Filter by posting status.
  40. pub status: Option<PostingStatus>,
  41. /// Max results to return.
  42. pub limit: Option<u32>,
  43. /// Number of results to skip.
  44. pub offset: Option<u32>,
  45. }
  46. /// Pagination and filtering parameters for transfer queries.
  47. #[derive(Debug, Clone, Default)]
  48. pub struct TransferQuery {
  49. /// Filter to transfers involving this base account.
  50. pub account: Option<i64>,
  51. /// Restrict to one subaccount; `None` spans every subaccount of `account`.
  52. pub sub: Option<i64>,
  53. /// Inclusive lower bound (unix millis).
  54. pub from_ts: Option<i64>,
  55. /// Exclusive upper bound (unix millis).
  56. pub to_ts: Option<i64>,
  57. /// Filter by book.
  58. pub book: Option<BookId>,
  59. /// Max results to return.
  60. pub limit: Option<u32>,
  61. /// Number of results to skip.
  62. pub offset: Option<u32>,
  63. }
  64. /// A page of results with total count for pagination.
  65. #[derive(Debug, Clone)]
  66. pub struct Page<T> {
  67. /// The items in this page.
  68. pub items: Vec<T>,
  69. /// Total number of matching items (before pagination).
  70. pub total: u64,
  71. }
  72. // ---------------------------------------------------------------------------
  73. // Sub-traits
  74. // ---------------------------------------------------------------------------
  75. /// Account persistence: create, version, query.
  76. #[async_trait]
  77. pub trait AccountStore: Send + Sync {
  78. /// Fetch a single account by id.
  79. async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError>;
  80. /// Fetch multiple accounts by id.
  81. async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError>;
  82. /// Persist a new account (version 1).
  83. async fn create_account(&self, account: Account) -> Result<(), StoreError>;
  84. /// Append a new version to an existing account.
  85. async fn append_account_version(&self, account: Account) -> Result<(), StoreError>;
  86. /// Return the full version history for an account.
  87. async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError>;
  88. /// List all accounts (latest version of each).
  89. async fn list_accounts(&self) -> Result<Vec<Account>, StoreError>;
  90. }
  91. /// Posting persistence: reads and lifecycle transitions.
  92. #[async_trait]
  93. pub trait PostingStore: Send + Sync {
  94. /// Fetch postings by their ids.
  95. async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError>;
  96. /// Return postings owned by a base account, optionally filtered by
  97. /// subaccount, asset, and/or status. `sub == None` spans every subaccount
  98. /// of `id`; `sub == Some(s)` restricts to that one subaccount.
  99. async fn get_postings_by_account(
  100. &self,
  101. id: i64,
  102. sub: Option<i64>,
  103. asset: Option<&AssetId>,
  104. status: Option<PostingStatus>,
  105. ) -> Result<Vec<Posting>, StoreError>;
  106. /// Reserve postings: `Active → PendingInactive`, stamping `reservation` as
  107. /// the owner token. A dumb instruction — each id flips only if still `Active`;
  108. /// returns the **number of rows reserved** (0 ≤ n ≤ ids.len()). It does not
  109. /// error on a short count; the caller (saga) interprets it.
  110. async fn reserve_postings(
  111. &self,
  112. ids: &[PostingId],
  113. reservation: ReservationId,
  114. ) -> Result<u64, StoreError>;
  115. /// Release postings: `PendingInactive` owned by `reservation` → `Active`,
  116. /// clearing the owner. A dumb instruction — only postings reserved by this
  117. /// `reservation` flip; returns the **number of rows released**. Releasing an
  118. /// `Active` (already released) or differently-owned posting simply does not
  119. /// count. The caller interprets the result.
  120. async fn release_postings(
  121. &self,
  122. ids: &[PostingId],
  123. reservation: ReservationId,
  124. ) -> Result<u64, StoreError>;
  125. /// Deactivate postings: flip to `Inactive`. A dumb instruction — it applies
  126. /// the conditional update and returns the **number of rows changed**; it does
  127. /// not decide whether that count is correct. The caller (saga) interprets it.
  128. /// - `reservation == None` (raw): only postings still `Active` flip.
  129. /// - `reservation == Some(rid)`: only postings `PendingInactive` owned by
  130. /// `rid` flip.
  131. /// Returns the count of postings actually transitioned (0 ≤ n ≤ ids.len()).
  132. async fn deactivate_postings(
  133. &self,
  134. ids: &[PostingId],
  135. reservation: Option<ReservationId>,
  136. ) -> Result<u64, StoreError>;
  137. /// Insert postings if absent (idempotent). A dumb instruction — inserts each
  138. /// posting unless one with the same id already exists, and returns the
  139. /// **number of rows inserted** (already-present postings contribute 0). The
  140. /// caller decides what a short count means.
  141. async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError>;
  142. /// Query postings with filtering and pagination.
  143. async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
  144. let all = self
  145. .get_postings_by_account(query.account, query.sub, query.asset.as_ref(), query.status)
  146. .await?;
  147. let total = all.len() as u64;
  148. let offset = query.offset.unwrap_or(0) as usize;
  149. let limit = query.limit.unwrap_or(u32::MAX) as usize;
  150. let items = all.into_iter().skip(offset).take(limit).collect();
  151. Ok(Page { items, total })
  152. }
  153. }
  154. /// Transfer persistence: store and query committed transfers.
  155. #[async_trait]
  156. pub trait TransferStore: Send + Sync {
  157. /// Fetch a transfer record by its content-addressed id.
  158. async fn get_transfer(&self, id: &EnvelopeId) -> Result<Option<EnvelopeRecord>, StoreError>;
  159. /// Persist a transfer record if absent (idempotent) and index it under every
  160. /// account in `involved` (both created and consumed owners — the caller
  161. /// supplies the set so storage computes nothing). A dumb instruction:
  162. /// returns **1** if the transfer row was newly inserted, **0** if it already
  163. /// existed. The caller decides what `0` means.
  164. async fn store_transfer(
  165. &self,
  166. record: EnvelopeRecord,
  167. involved: &[AccountId],
  168. ) -> Result<u64, StoreError>;
  169. /// Return all transfers involving the given base account. `sub == None`
  170. /// spans every subaccount of `id`; `sub == Some(s)` restricts to one.
  171. async fn get_transfers_for_account(
  172. &self,
  173. id: i64,
  174. sub: Option<i64>,
  175. ) -> Result<Vec<EnvelopeRecord>, StoreError>;
  176. /// Query transfers with filtering and pagination.
  177. async fn query_transfers(
  178. &self,
  179. query: &TransferQuery,
  180. ) -> Result<Page<EnvelopeRecord>, StoreError> {
  181. // Default in-memory implementation
  182. let all = if let Some(account) = query.account {
  183. self.get_transfers_for_account(account, query.sub).await?
  184. } else {
  185. return Err(StoreError::Internal(
  186. "query_transfers requires account filter in default implementation".into(),
  187. ));
  188. };
  189. let filtered: Vec<EnvelopeRecord> = all
  190. .into_iter()
  191. .filter(|r| {
  192. if let Some(from) = query.from_ts
  193. && r.created_at < from
  194. {
  195. return false;
  196. }
  197. if let Some(to) = query.to_ts
  198. && r.created_at >= to
  199. {
  200. return false;
  201. }
  202. if let Some(book) = query.book
  203. && r.envelope.book() != book
  204. {
  205. return false;
  206. }
  207. true
  208. })
  209. .collect();
  210. let total = filtered.len() as u64;
  211. let offset = query.offset.unwrap_or(0) as usize;
  212. let limit = query.limit.unwrap_or(u32::MAX) as usize;
  213. let items = filtered.into_iter().skip(offset).take(limit).collect();
  214. Ok(Page { items, total })
  215. }
  216. }
  217. /// Saga state persistence for crash recovery.
  218. #[async_trait]
  219. pub trait SagaStore: Send + Sync {
  220. /// Persist a saga execution state.
  221. async fn save_saga(&self, id: &i64, data: Vec<u8>) -> Result<(), StoreError>;
  222. /// Load all pending (incomplete) saga states.
  223. async fn list_pending_sagas(&self) -> Result<Vec<(i64, Vec<u8>)>, StoreError>;
  224. /// Delete a completed saga state.
  225. async fn delete_saga(&self, id: &i64) -> Result<(), StoreError>;
  226. }
  227. /// Book persistence.
  228. #[async_trait]
  229. pub trait BookStore: Send + Sync {
  230. /// Create a new book.
  231. async fn create_book(&self, book: Book) -> Result<(), StoreError>;
  232. /// Fetch a book by id.
  233. async fn get_book(&self, id: &BookId) -> Result<Book, StoreError>;
  234. /// List all books.
  235. async fn list_books(&self) -> Result<Vec<Book>, StoreError>;
  236. }
  237. // ---------------------------------------------------------------------------
  238. // Composite trait
  239. // ---------------------------------------------------------------------------
  240. /// Async storage abstraction composing all sub-traits.
  241. pub trait Store:
  242. AccountStore + PostingStore + TransferStore + SagaStore + EventStore + BookStore
  243. {
  244. }
  245. impl<T: AccountStore + PostingStore + TransferStore + SagaStore + EventStore + BookStore> Store
  246. for T
  247. {
  248. }