sqlite.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. #![allow(missing_docs)]
  2. #![cfg(feature = "sqlite")]
  3. use kuatia_storage::store::{AccountStore, PostingStore};
  4. use kuatia_storage_sql::SqlStore;
  5. use kuatia_types::*;
  6. use sqlx::{Any, Pool, Row};
  7. async fn new_pool() -> Pool<Any> {
  8. sqlx::any::install_default_drivers();
  9. sqlx::any::AnyPoolOptions::new()
  10. .max_connections(1)
  11. .connect("sqlite::memory:")
  12. .await
  13. .unwrap()
  14. }
  15. async fn new_store() -> SqlStore {
  16. let store = SqlStore::new(new_pool().await);
  17. store.migrate().await.unwrap();
  18. store
  19. }
  20. kuatia_storage::store_tests!(new_store);
  21. /// The point of the schema: no column holds opaque binary. A content-addressed
  22. /// id is stored as lower-case hex text, and a structured payload as readable
  23. /// JSON text, so a row can be audited in a plain SQL client.
  24. #[tokio::test]
  25. async fn columns_store_hex_ids_and_json_text() {
  26. let pool = new_pool().await;
  27. let store = SqlStore::new(pool.clone());
  28. store.migrate().await.unwrap();
  29. let account = Account {
  30. id: AccountId::new(1),
  31. version: 1,
  32. flags: AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT,
  33. book: BookId(0),
  34. metadata: std::collections::BTreeMap::new(),
  35. };
  36. store.create_account(account).await.unwrap();
  37. let tid = EnvelopeId([0xab; 32]);
  38. let posting = Posting::new(
  39. PostingId {
  40. transfer: tid,
  41. index: 0,
  42. },
  43. AccountId::new(1),
  44. AssetId::new(1),
  45. Cent::from(100),
  46. );
  47. store.insert_postings(&[posting]).await.unwrap();
  48. // The 32-byte transfer id is stored as its 64-char lower-case hex form.
  49. let row = sqlx::query("SELECT transfer_id FROM postings")
  50. .fetch_one(&pool)
  51. .await
  52. .unwrap();
  53. let transfer_id: String = row.try_get("transfer_id").unwrap();
  54. assert_eq!(transfer_id, "ab".repeat(32));
  55. // The account payload is readable JSON text, not a blob.
  56. let row = sqlx::query("SELECT metadata FROM accounts")
  57. .fetch_one(&pool)
  58. .await
  59. .unwrap();
  60. let metadata: String = row.try_get("metadata").unwrap();
  61. assert!(
  62. serde_json::from_str::<serde_json::Value>(&metadata).is_ok(),
  63. "metadata should be JSON text, got: {metadata}"
  64. );
  65. }
  66. /// The 002 migration adds the subaccount column and a subaccount account/posting
  67. /// round-trips through the schema, kept distinct from the main account.
  68. #[tokio::test]
  69. async fn subaccount_columns_round_trip() {
  70. let store = new_store().await;
  71. let sub = AccountId::with_sub(1, 7);
  72. store
  73. .create_account(Account::debit_must_not_exceed_credit(sub))
  74. .await
  75. .unwrap();
  76. // The main account (1, 0) is a separate record.
  77. store
  78. .create_account(Account::debit_must_not_exceed_credit(AccountId::new(1)))
  79. .await
  80. .unwrap();
  81. let got = store.get_account(&sub).await.unwrap();
  82. assert_eq!(got.id, sub);
  83. let posting = Posting::new(
  84. PostingId {
  85. transfer: EnvelopeId([0xcd; 32]),
  86. index: 0,
  87. },
  88. sub,
  89. AssetId::new(1),
  90. Cent::from(500),
  91. );
  92. store.insert_postings(&[posting]).await.unwrap();
  93. // Filtering by the subaccount returns it; the main account holds nothing.
  94. let by_sub = store
  95. .get_postings_by_account(1, Some(7), None, PostingFilter::All)
  96. .await
  97. .unwrap();
  98. assert_eq!(by_sub.len(), 1);
  99. assert_eq!(by_sub[0].owner, sub);
  100. let by_main = store
  101. .get_postings_by_account(1, Some(0), None, PostingFilter::All)
  102. .await
  103. .unwrap();
  104. assert!(by_main.is_empty());
  105. }
  106. /// A database created under 001 (no subaccount column) upgrades in place: the
  107. /// 002 migration rebuilds the tables and existing rows default to subaccount 0,
  108. /// the main account.
  109. #[tokio::test]
  110. async fn migration_upgrades_existing_rows_to_main_account() {
  111. let pool = new_pool().await;
  112. // Pre-002 schema: the tables 002 rebuilds, in their old (no-subaccount) shape.
  113. sqlx::query(
  114. "CREATE TABLE accounts (id BIGINT NOT NULL, version BIGINT NOT NULL, policy TEXT NOT NULL, flags INTEGER NOT NULL, book BIGINT NOT NULL, user_data TEXT NOT NULL, metadata TEXT NOT NULL, PRIMARY KEY (id, version))",
  115. )
  116. .execute(&pool)
  117. .await
  118. .unwrap();
  119. sqlx::query(
  120. "CREATE TABLE postings (transfer_id TEXT NOT NULL, idx SMALLINT NOT NULL, owner BIGINT NOT NULL, asset INTEGER NOT NULL, value TEXT NOT NULL, status SMALLINT NOT NULL, reservation BIGINT, PRIMARY KEY (transfer_id, idx))",
  121. )
  122. .execute(&pool)
  123. .await
  124. .unwrap();
  125. sqlx::query("CREATE INDEX idx_postings_owner ON postings(owner, asset, status)")
  126. .execute(&pool)
  127. .await
  128. .unwrap();
  129. sqlx::query(
  130. "CREATE TABLE transfer_accounts (transfer_id TEXT NOT NULL, account_id BIGINT NOT NULL, PRIMARY KEY (transfer_id, account_id))",
  131. )
  132. .execute(&pool)
  133. .await
  134. .unwrap();
  135. sqlx::query("CREATE INDEX idx_xfer_acct ON transfer_accounts(account_id)")
  136. .execute(&pool)
  137. .await
  138. .unwrap();
  139. // Legacy 001/002 schema carried a user_data JSON column; the 003 migration
  140. // drops it. Seed it with the value the old UserData type serialized to.
  141. let user_data = r#"{"d128":0,"d64":0,"d32":0}"#.to_string();
  142. let metadata =
  143. serde_json::to_string(&std::collections::BTreeMap::<String, String>::new()).unwrap();
  144. sqlx::query(
  145. "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES (5, 1, '\"NoOverdraft\"', 0, 0, $1, $2)",
  146. )
  147. .bind(user_data)
  148. .bind(metadata)
  149. .execute(&pool)
  150. .await
  151. .unwrap();
  152. // Record 001 as applied so migrate() only runs 002 against this schema.
  153. sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
  154. .execute(&pool)
  155. .await
  156. .unwrap();
  157. sqlx::query("INSERT INTO _migrations (name) VALUES ('001_init')")
  158. .execute(&pool)
  159. .await
  160. .unwrap();
  161. let store = SqlStore::new(pool);
  162. store.migrate().await.unwrap();
  163. // The pre-existing account is now the main account (subaccount 0).
  164. let got = store.get_account(&AccountId::new(5)).await.unwrap();
  165. assert_eq!(got.id, AccountId::new(5));
  166. assert!(got.id.is_main());
  167. }
  168. /// The 004 migration splits lifecycle state out of `postings` into the two
  169. /// id-only index tables: an active (status 0) posting is backfilled into
  170. /// `active_postings`, a reserved (status 1) posting into `reserved_postings`
  171. /// with its token, and the `status` column is dropped from `postings`.
  172. #[tokio::test]
  173. async fn migration_004_backfills_index_tables() {
  174. let pool = new_pool().await;
  175. // Pre-004 postings schema (the shape 001->003 leave it): status + reservation.
  176. sqlx::query(
  177. "CREATE TABLE 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, status SMALLINT NOT NULL, reservation BIGINT, PRIMARY KEY (transfer_id, idx))",
  178. )
  179. .execute(&pool)
  180. .await
  181. .unwrap();
  182. // One active (status 0) and one reserved (status 1, reservation 77) posting.
  183. sqlx::query("INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value, status, reservation) VALUES ('aa', 0, 1, 0, 1, '100', 0, NULL)")
  184. .execute(&pool)
  185. .await
  186. .unwrap();
  187. sqlx::query("INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value, status, reservation) VALUES ('bb', 0, 1, 0, 1, '200', 1, 77)")
  188. .execute(&pool)
  189. .await
  190. .unwrap();
  191. // A post-003 DB also has the accounts table (empty here); migrate() will run
  192. // 005 after 004, which backfills the account head from it.
  193. sqlx::query(
  194. "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))",
  195. )
  196. .execute(&pool)
  197. .await
  198. .unwrap();
  199. // Record 001-003 as applied so migrate() only runs 004 and 005.
  200. sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
  201. .execute(&pool)
  202. .await
  203. .unwrap();
  204. for m in ["001_init", "002_subaccounts", "003_drop_user_data"] {
  205. sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
  206. .bind(m)
  207. .execute(&pool)
  208. .await
  209. .unwrap();
  210. }
  211. let store = SqlStore::new(pool.clone());
  212. store.migrate().await.unwrap();
  213. // The active posting is now in active_postings, carrying a full row copy.
  214. let active = sqlx::query("SELECT transfer_id, owner, asset, value FROM active_postings")
  215. .fetch_all(&pool)
  216. .await
  217. .unwrap();
  218. assert_eq!(active.len(), 1);
  219. let a_tid: String = active[0].try_get("transfer_id").unwrap();
  220. let a_owner: i64 = active[0].try_get("owner").unwrap();
  221. let a_value: String = active[0].try_get("value").unwrap();
  222. assert_eq!(a_tid, "aa");
  223. assert_eq!(a_owner, 1);
  224. assert_eq!(a_value, "100");
  225. // The reserved posting is in reserved_postings with its data copy and token.
  226. let reserved = sqlx::query("SELECT transfer_id, value, reservation FROM reserved_postings")
  227. .fetch_all(&pool)
  228. .await
  229. .unwrap();
  230. assert_eq!(reserved.len(), 1);
  231. let r_tid: String = reserved[0].try_get("transfer_id").unwrap();
  232. let r_value: String = reserved[0].try_get("value").unwrap();
  233. let r_res: i64 = reserved[0].try_get("reservation").unwrap();
  234. assert_eq!(r_tid, "bb");
  235. assert_eq!(r_value, "200");
  236. assert_eq!(r_res, 77);
  237. // Both immutable rows survive; the status column is gone.
  238. let all = sqlx::query("SELECT transfer_id FROM postings")
  239. .fetch_all(&pool)
  240. .await
  241. .unwrap();
  242. assert_eq!(all.len(), 2);
  243. assert!(
  244. sqlx::query("SELECT status FROM postings")
  245. .fetch_all(&pool)
  246. .await
  247. .is_err()
  248. );
  249. }
  250. /// The 005 migration backfills `account_head` with the current (highest)
  251. /// version of each account, so a subsequent read hits one row per account
  252. /// without scanning the version history.
  253. #[tokio::test]
  254. async fn migration_005_backfills_account_head() {
  255. let pool = new_pool().await;
  256. // Pre-005 accounts schema (post-004 shape) with a versioned history:
  257. // account 1 has three versions, account 2 has one.
  258. sqlx::query(
  259. "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))",
  260. )
  261. .execute(&pool)
  262. .await
  263. .unwrap();
  264. for (id, version) in [(1, 1), (1, 2), (1, 3), (2, 1)] {
  265. sqlx::query("INSERT INTO accounts (id, subaccount, version, policy, flags, book, metadata) VALUES ($1, 0, $2, '\"NoOverdraft\"', 0, 0, '{}')")
  266. .bind(id as i64)
  267. .bind(version as i64)
  268. .execute(&pool)
  269. .await
  270. .unwrap();
  271. }
  272. // Record 001-004 as applied so migrate() only runs 005.
  273. sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
  274. .execute(&pool)
  275. .await
  276. .unwrap();
  277. for m in [
  278. "001_init",
  279. "002_subaccounts",
  280. "003_drop_user_data",
  281. "004_index_tables",
  282. ] {
  283. sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
  284. .bind(m)
  285. .execute(&pool)
  286. .await
  287. .unwrap();
  288. }
  289. let store = SqlStore::new(pool.clone());
  290. store.migrate().await.unwrap();
  291. // One head per account, each pointing at the highest version.
  292. let mut heads: Vec<(i64, i64)> = sqlx::query("SELECT id, version FROM account_head")
  293. .fetch_all(&pool)
  294. .await
  295. .unwrap()
  296. .iter()
  297. .map(|r| (r.try_get("id").unwrap(), r.try_get("version").unwrap()))
  298. .collect();
  299. heads.sort();
  300. assert_eq!(heads, vec![(1, 3), (2, 1)]);
  301. // The store reads the current version through the head.
  302. let acct = store.get_account(&AccountId::new(1)).await.unwrap();
  303. assert_eq!(acct.version, 3);
  304. }
  305. /// migrate() is idempotent: running it repeatedly on the same DB is a no-op.
  306. #[tokio::test]
  307. async fn migrate_is_idempotent() {
  308. sqlx::any::install_default_drivers();
  309. let pool = sqlx::any::AnyPoolOptions::new()
  310. .max_connections(1)
  311. .connect("sqlite::memory:")
  312. .await
  313. .unwrap();
  314. let store = SqlStore::new(pool);
  315. store.migrate().await.unwrap();
  316. store.migrate().await.unwrap();
  317. store.migrate().await.unwrap();
  318. }
  319. /// The id-batch primitives chunk a large id set so it never exceeds the
  320. /// backend's bind-parameter limit (SQLite caps a statement at 32766 variables).
  321. /// A batch larger than one chunk must reserve, read, and deactivate every id
  322. /// exactly as a small one does.
  323. #[tokio::test]
  324. async fn id_batch_primitives_chunk_large_batches() {
  325. // Comfortably larger than the internal chunk size so the batch spans
  326. // multiple statements.
  327. const N: u16 = 9000;
  328. let store = new_store().await;
  329. let tid = EnvelopeId([0xcd; 32]);
  330. let postings: Vec<Posting> = (0..N)
  331. .map(|i| {
  332. Posting::new(
  333. PostingId {
  334. transfer: tid,
  335. index: i,
  336. },
  337. AccountId::new(1),
  338. AssetId::new(1),
  339. Cent::from(1),
  340. )
  341. })
  342. .collect();
  343. let ids: Vec<PostingId> = postings.iter().map(|p| p.id).collect();
  344. assert_eq!(
  345. store.insert_postings(&postings).await.unwrap(),
  346. u64::from(N)
  347. );
  348. // Reserve the whole batch in one call: every id is claimed across chunks.
  349. let rid = ReservationId::new(1);
  350. assert_eq!(
  351. store.reserve_postings(&ids, rid).await.unwrap(),
  352. u64::from(N)
  353. );
  354. // Reads span chunks and return every id.
  355. let states = store.get_posting_states(&ids).await.unwrap();
  356. assert_eq!(states.len(), N as usize);
  357. assert!(states.iter().all(|s| *s == PostingState::Reserved(rid)));
  358. assert_eq!(store.get_postings(&ids).await.unwrap().len(), N as usize);
  359. // Deactivating the whole batch spends every id.
  360. assert_eq!(
  361. store.deactivate_postings(&ids, Some(rid)).await.unwrap(),
  362. u64::from(N)
  363. );
  364. let after = store.get_posting_states(&ids).await.unwrap();
  365. assert!(after.iter().all(|s| *s == PostingState::Spent));
  366. }