sqlite.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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).
  192. sqlx::query(
  193. "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))",
  194. )
  195. .execute(&pool)
  196. .await
  197. .unwrap();
  198. // Record every migration except 004 as applied so migrate() runs only 004,
  199. // isolating its backfill. In particular 008 (which merges the index tables
  200. // into `live_postings`) is pinned as applied so it does not run and clobber
  201. // the active/reserved tables this test inspects.
  202. sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
  203. .execute(&pool)
  204. .await
  205. .unwrap();
  206. for m in [
  207. "001_init",
  208. "002_subaccounts",
  209. "003_drop_user_data",
  210. "005_account_head",
  211. "006_drop_policy",
  212. "007_balance_projection",
  213. "008_live_postings",
  214. ] {
  215. sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
  216. .bind(m)
  217. .execute(&pool)
  218. .await
  219. .unwrap();
  220. }
  221. let store = SqlStore::new(pool.clone());
  222. store.migrate().await.unwrap();
  223. // The active posting is now in active_postings, carrying a full row copy.
  224. let active = sqlx::query("SELECT transfer_id, owner, asset, value FROM active_postings")
  225. .fetch_all(&pool)
  226. .await
  227. .unwrap();
  228. assert_eq!(active.len(), 1);
  229. let a_tid: String = active[0].try_get("transfer_id").unwrap();
  230. let a_owner: i64 = active[0].try_get("owner").unwrap();
  231. let a_value: String = active[0].try_get("value").unwrap();
  232. assert_eq!(a_tid, "aa");
  233. assert_eq!(a_owner, 1);
  234. assert_eq!(a_value, "100");
  235. // The reserved posting is in reserved_postings with its data copy and token.
  236. let reserved = sqlx::query("SELECT transfer_id, value, reservation FROM reserved_postings")
  237. .fetch_all(&pool)
  238. .await
  239. .unwrap();
  240. assert_eq!(reserved.len(), 1);
  241. let r_tid: String = reserved[0].try_get("transfer_id").unwrap();
  242. let r_value: String = reserved[0].try_get("value").unwrap();
  243. let r_res: i64 = reserved[0].try_get("reservation").unwrap();
  244. assert_eq!(r_tid, "bb");
  245. assert_eq!(r_value, "200");
  246. assert_eq!(r_res, 77);
  247. // Both immutable rows survive; the status column is gone.
  248. let all = sqlx::query("SELECT transfer_id FROM postings")
  249. .fetch_all(&pool)
  250. .await
  251. .unwrap();
  252. assert_eq!(all.len(), 2);
  253. assert!(
  254. sqlx::query("SELECT status FROM postings")
  255. .fetch_all(&pool)
  256. .await
  257. .is_err()
  258. );
  259. }
  260. /// The 005 migration backfills `account_head` with the current (highest)
  261. /// version of each account, so a subsequent read hits one row per account
  262. /// without scanning the version history.
  263. #[tokio::test]
  264. async fn migration_005_backfills_account_head() {
  265. let pool = new_pool().await;
  266. // Pre-005 accounts schema (post-004 shape) with a versioned history:
  267. // account 1 has three versions, account 2 has one.
  268. sqlx::query(
  269. "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))",
  270. )
  271. .execute(&pool)
  272. .await
  273. .unwrap();
  274. for (id, version) in [(1, 1), (1, 2), (1, 3), (2, 1)] {
  275. sqlx::query("INSERT INTO accounts (id, subaccount, version, policy, flags, book, metadata) VALUES ($1, 0, $2, '\"NoOverdraft\"', 0, 0, '{}')")
  276. .bind(id as i64)
  277. .bind(version as i64)
  278. .execute(&pool)
  279. .await
  280. .unwrap();
  281. }
  282. // Record every migration except 005 as applied so migrate() runs only 005.
  283. // 008 is pinned as applied so it does not run against a DB whose 004 index
  284. // tables were never created.
  285. sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
  286. .execute(&pool)
  287. .await
  288. .unwrap();
  289. for m in [
  290. "001_init",
  291. "002_subaccounts",
  292. "003_drop_user_data",
  293. "004_index_tables",
  294. "006_drop_policy",
  295. "007_balance_projection",
  296. "008_live_postings",
  297. ] {
  298. sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
  299. .bind(m)
  300. .execute(&pool)
  301. .await
  302. .unwrap();
  303. }
  304. let store = SqlStore::new(pool.clone());
  305. store.migrate().await.unwrap();
  306. // One head per account, each pointing at the highest version.
  307. let mut heads: Vec<(i64, i64)> = sqlx::query("SELECT id, version FROM account_head")
  308. .fetch_all(&pool)
  309. .await
  310. .unwrap()
  311. .iter()
  312. .map(|r| (r.try_get("id").unwrap(), r.try_get("version").unwrap()))
  313. .collect();
  314. heads.sort();
  315. assert_eq!(heads, vec![(1, 3), (2, 1)]);
  316. // The store reads the current version through the head.
  317. let acct = store.get_account(&AccountId::new(1)).await.unwrap();
  318. assert_eq!(acct.version, 3);
  319. }
  320. /// The 008 migration merges the two index tables into one `live_postings`
  321. /// table: an active row is backfilled with `reservation` NULL, a reserved row
  322. /// keeps its token, and the old `active_postings`/`reserved_postings` tables are
  323. /// dropped.
  324. #[tokio::test]
  325. async fn migration_008_merges_live_postings() {
  326. let pool = new_pool().await;
  327. // Post-004 index tables with one active and one reserved posting.
  328. sqlx::query(
  329. "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))",
  330. )
  331. .execute(&pool)
  332. .await
  333. .unwrap();
  334. sqlx::query(
  335. "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))",
  336. )
  337. .execute(&pool)
  338. .await
  339. .unwrap();
  340. sqlx::query("INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ('aa', 0, 1, 0, 1, '100')")
  341. .execute(&pool)
  342. .await
  343. .unwrap();
  344. sqlx::query("INSERT INTO reserved_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) VALUES ('bb', 0, 1, 0, 1, '200', 77)")
  345. .execute(&pool)
  346. .await
  347. .unwrap();
  348. // Record every migration except 008 as applied so migrate() runs only 008.
  349. sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
  350. .execute(&pool)
  351. .await
  352. .unwrap();
  353. for m in [
  354. "001_init",
  355. "002_subaccounts",
  356. "003_drop_user_data",
  357. "004_index_tables",
  358. "005_account_head",
  359. "006_drop_policy",
  360. "007_balance_projection",
  361. ] {
  362. sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
  363. .bind(m)
  364. .execute(&pool)
  365. .await
  366. .unwrap();
  367. }
  368. let store = SqlStore::new(pool.clone());
  369. store.migrate().await.unwrap();
  370. // The active row lands with reservation NULL, the reserved row keeps its token.
  371. let mut rows: Vec<(String, Option<i64>)> =
  372. sqlx::query("SELECT transfer_id, reservation FROM live_postings")
  373. .fetch_all(&pool)
  374. .await
  375. .unwrap()
  376. .iter()
  377. .map(|r| {
  378. (
  379. r.try_get("transfer_id").unwrap(),
  380. r.try_get("reservation").unwrap(),
  381. )
  382. })
  383. .collect();
  384. rows.sort();
  385. assert_eq!(
  386. rows,
  387. vec![("aa".to_string(), None), ("bb".to_string(), Some(77))]
  388. );
  389. // The old index tables are gone.
  390. assert!(
  391. sqlx::query("SELECT 1 FROM active_postings")
  392. .fetch_all(&pool)
  393. .await
  394. .is_err()
  395. );
  396. assert!(
  397. sqlx::query("SELECT 1 FROM reserved_postings")
  398. .fetch_all(&pool)
  399. .await
  400. .is_err()
  401. );
  402. }
  403. /// migrate() is idempotent: running it repeatedly on the same DB is a no-op.
  404. #[tokio::test]
  405. async fn migrate_is_idempotent() {
  406. sqlx::any::install_default_drivers();
  407. let pool = sqlx::any::AnyPoolOptions::new()
  408. .max_connections(1)
  409. .connect("sqlite::memory:")
  410. .await
  411. .unwrap();
  412. let store = SqlStore::new(pool);
  413. store.migrate().await.unwrap();
  414. store.migrate().await.unwrap();
  415. store.migrate().await.unwrap();
  416. }
  417. /// The id-batch primitives chunk a large id set so it never exceeds the
  418. /// backend's bind-parameter limit (SQLite caps a statement at 32766 variables).
  419. /// A batch larger than one chunk must reserve, read, and deactivate every id
  420. /// exactly as a small one does.
  421. #[tokio::test]
  422. async fn id_batch_primitives_chunk_large_batches() {
  423. // Comfortably larger than the internal chunk size so the batch spans
  424. // multiple statements.
  425. const N: u16 = 9000;
  426. let store = new_store().await;
  427. let tid = EnvelopeId([0xcd; 32]);
  428. let postings: Vec<Posting> = (0..N)
  429. .map(|i| {
  430. Posting::new(
  431. PostingId {
  432. transfer: tid,
  433. index: i,
  434. },
  435. AccountId::new(1),
  436. AssetId::new(1),
  437. Cent::from(1),
  438. )
  439. })
  440. .collect();
  441. let ids: Vec<PostingId> = postings.iter().map(|p| p.id).collect();
  442. assert_eq!(
  443. store.insert_postings(&postings).await.unwrap(),
  444. u64::from(N)
  445. );
  446. // Reserve the whole batch in one call: every id is claimed across chunks.
  447. let rid = ReservationId::new(1);
  448. assert_eq!(
  449. store.reserve_postings(&ids, rid).await.unwrap(),
  450. u64::from(N)
  451. );
  452. // Reads span chunks and return every id.
  453. let states = store.get_posting_states(&ids).await.unwrap();
  454. assert_eq!(states.len(), N as usize);
  455. assert!(states.iter().all(|s| *s == PostingState::Reserved(rid)));
  456. assert_eq!(store.get_postings(&ids).await.unwrap().len(), N as usize);
  457. // Deactivating the whole batch spends every id.
  458. assert_eq!(
  459. store.deactivate_postings(&ids, Some(rid)).await.unwrap(),
  460. u64::from(N)
  461. );
  462. let after = store.get_posting_states(&ids).await.unwrap();
  463. assert!(after.iter().all(|s| *s == PostingState::Spent));
  464. }