Эх сурвалжийг харах

Cover the Postgres backend and i128 backing in CI, and fill the changelog

The SQL backend's Postgres-only code paths (FOR UPDATE row locks, ON CONFLICT
upserts) were never exercised: CI ran only the SQLite default, and the i128
Cent backing was never tested at all. Split the test job into three so both
gaps are covered: SQLite/i64, i128 backing, and a PostgreSQL conformance run
against a real database.

Add a Postgres conformance harness that runs the same store_tests! suite, with
per-test schema isolation because all tests share one database (unlike
sqlite::memory:). Gate it behind a dedicated test-postgres feature rather than
the postgres backend feature: the dashboard depends on the SQL crate with
postgres, so a plain cargo test unifies that feature on, and a shared gate
would make the DB-requiring suite run (and fail) without a DATABASE_URL.

Fill the changelog's Unreleased section from the history since the 0.2.0
release: subaccounts, inflight holds, IBAN account ids, the cached balance
projection, the affected-row storage contract, and the recent internal
consolidations.
Cesar Rodas 1 долоо хоног өмнө
parent
commit
04193969e3

+ 40 - 1
.github/workflows/ci.yml

@@ -43,10 +43,49 @@ jobs:
       - run: cargo doc --workspace --all-features --no-deps
 
   test:
-    name: Test
+    name: Test (SQLite, i64)
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
       - uses: dtolnay/rust-toolchain@stable
       - uses: Swatinem/rust-cache@v2
+      # Default features: the SQLite backend with the i64 Cent backing.
       - run: cargo test --all
+
+  test-i128:
+    name: Test (i128 backing)
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - uses: Swatinem/rust-cache@v2
+      # Swap the Cent backing to i128 across the whole chain. Dashboard has no
+      # i128 feature, so exclude it from the feature-scoped run.
+      - run: cargo test --workspace --exclude kuatia-dashboard --features i128
+
+  test-postgres:
+    name: Test (PostgreSQL)
+    runs-on: ubuntu-latest
+    services:
+      postgres:
+        image: postgres:16
+        env:
+          POSTGRES_USER: kuatia
+          POSTGRES_PASSWORD: kuatia
+          POSTGRES_DB: kuatia
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U kuatia"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+    env:
+      DATABASE_URL: postgres://kuatia:kuatia@localhost:5432/kuatia
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - uses: Swatinem/rust-cache@v2
+      # Run only the SQL backend's conformance suite against real Postgres, so
+      # the Postgres-only code paths (FOR UPDATE locks, ON CONFLICT) are covered.
+      - run: cargo test -p kuatia-storage-sql --no-default-features --features test-postgres

+ 43 - 0
CHANGELOG.md

@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- Subaccounts: `AccountId` gains a subaccount leg (`{ id, sub }`), so several
+  independent balances live under one owner, each individually addressable,
+  drained, and closed. Aggregate reads take a base id plus an optional
+  subaccount filter; exact operations take the full `AccountId`; balances are
+  never summed across subaccounts. (ADR-0012)
+- Inflight holds: authorize, confirm, and void value through per-destination
+  holding subaccounts, so an authorization reserves funds without committing
+  them and a void returns them. (ADR-0014)
+- IBAN-style account identifiers: `AccountId` has a fixed 20-character
+  `Display`/`FromStr` form (a base-36 body plus two mod-97 check digits) for
+  presentation and routing, while storage keeps the two integer legs. (ADR-0015)
+- Cached balance projection: an append-only balance snapshot, refreshed lazily
+  off the read path once enough activity accrues, shortens the everyday balance
+  read; the authoritative live-posting sum stays the source of truth. (ADR-0019)
+- `SagaStore::get_saga`, a keyed read for a single write-ahead record, so
+  recovery no longer scans every pending record.
+- Continuous integration now runs the storage conformance suite against
+  PostgreSQL (exercising the Postgres-only `FOR UPDATE` and `ON CONFLICT` paths)
+  and runs the test suite with the i128 Cent backing.
+
+### Changed
+
+- Storage write contract: every `Store` write method returns the number of
+  affected rows and makes no domain decision. The saga interprets counts and
+  owns idempotency and compensation, with a phase-tracked write-ahead record and
+  roll-forward crash recovery in place of a monolithic commit transaction.
+  (ADR-0003) *(breaking for `Store` implementors)*
+- Account balance constraint collapsed to the single
+  `DEBIT_MUST_NOT_EXCEED_CREDIT` flag; overdraft is allowed by default. (ADR-0018)
+- The SQL schema separates append-only value tables from disposable
+  active/reserved index tables. (ADR-0016, ADR-0017)
+- Intent resolution is pure and preserves typed errors across the saga.
+- Internal structure: the `Ledger` was split into concern-named submodules; the
+  commit-safety invariant is documented and its double-spend guard named
+  (ADR-0021); the write-ahead recovery record was concentrated into one
+  `pending` module; balance computation was consolidated into one module.
+
+### Removed
+
+- The vestigial `UserData` type and the orphaned withdraw saga step. *(breaking)*
+
 ### Documentation
 
 - Document that the ledger supports journaling: a committed transfer is a

+ 5 - 0
crates/kuatia-storage-sql/Cargo.toml

@@ -33,5 +33,10 @@ serde_json.workspace = true
 default = ["sqlite"]
 sqlite = ["sqlx/sqlite"]
 postgres = ["sqlx/postgres"]
+# Opt-in gate for the Postgres conformance suite (`tests/postgres.rs`), which
+# needs a live `DATABASE_URL`. Kept distinct from `postgres` so that a plain
+# `cargo test` (which unifies `postgres` on via the dashboard's dependency) does
+# not try to run it; only the dedicated CI job enables `test-postgres`.
+test-postgres = ["postgres"]
 # Pass through to the domain crates: swap the Cent backing to i128.
 i128 = ["kuatia-types/i128", "kuatia-storage/i128"]

+ 55 - 0
crates/kuatia-storage-sql/tests/postgres.rs

@@ -0,0 +1,55 @@
+#![allow(missing_docs)]
+#![cfg(feature = "test-postgres")]
+
+//! PostgreSQL conformance run.
+//!
+//! The same `store_tests!` suite the SQLite backend passes, driven against a
+//! real PostgreSQL instance so the Postgres-only code paths (the `FOR UPDATE`
+//! row locks behind `lock_clause`, the `ON CONFLICT` upserts) are actually
+//! exercised. Point `DATABASE_URL` at a Postgres database to run it; the CI
+//! `Test (PostgreSQL)` job supplies one.
+//!
+//! Unlike `sqlite::memory:`, where every pool is its own fresh database, all
+//! tests here share one Postgres database and the conformance tests reuse fixed
+//! ids. Each store therefore gets its own uniquely-named schema, and the pool is
+//! pinned to a single connection so the session `search_path` set below persists
+//! for the store's whole lifetime.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use kuatia_storage_sql::SqlStore;
+use sqlx::{Any, Pool};
+
+static SCHEMA_SEQ: AtomicU64 = AtomicU64::new(0);
+
+async fn new_store() -> SqlStore {
+    sqlx::any::install_default_drivers();
+    let url = std::env::var("DATABASE_URL")
+        .expect("DATABASE_URL must point at a PostgreSQL instance for this suite");
+
+    // One connection per store so the session-level `search_path` set below
+    // survives across every query the store issues.
+    let pool: Pool<Any> = sqlx::any::AnyPoolOptions::new()
+        .max_connections(1)
+        .connect(&url)
+        .await
+        .unwrap();
+
+    // Isolate each store in its own schema: the conformance tests reuse fixed
+    // ids, so a shared schema would collide across tests.
+    let n = SCHEMA_SEQ.fetch_add(1, Ordering::Relaxed);
+    let schema = format!("conformance_{n}");
+    for stmt in [
+        format!("DROP SCHEMA IF EXISTS {schema} CASCADE"),
+        format!("CREATE SCHEMA {schema}"),
+        format!("SET search_path TO {schema}"),
+    ] {
+        sqlx::query(&stmt).execute(&pool).await.unwrap();
+    }
+
+    let store = SqlStore::new(pool);
+    store.migrate().await.unwrap();
+    store
+}
+
+kuatia_storage::store_tests!(new_store);