Skip to content

feat(account): SQLite Accounts repository (task 20)#124

Merged
mpiton merged 3 commits intomainfrom
feat/task-20-repo-accounts
Apr 28, 2026
Merged

feat(account): SQLite Accounts repository (task 20)#124
mpiton merged 3 commits intomainfrom
feat/task-20-repo-accounts

Conversation

@mpiton
Copy link
Copy Markdown
Owner

@mpiton mpiton commented Apr 28, 2026

Summary

• Add accounts SQLite table migration with UNIQUE(service_name, username) constraint
• Refactor Account domain model: id → AccountId(String) newtype matching TEXT PRIMARY KEY spec
• Add missing fields: traffic_total, last_validated, created_at
• Sea-orm entity with from_domain/into_domain converters handling type conversions
AccountRepository driven port with find_by_id, save, list, list_by_service, delete
SqliteAccountRepo adapter implementing the port with proper UNIQUE violation error handling
• Credentials stored in keyring only — SQLite refs via Account::credential_ref() method
• Comprehensive test coverage: domain 99.70%, adapter 95.81%, migrations + UNIQUE constraint tests

Type

feat (new persistence layer)

Verification

  • ✅ All 1049 workspace tests pass
  • ✅ Coverage exceeds 85% threshold
  • ✅ clippy clean, fmt applied
  • ✅ Migration safety verified on existing data
  • ✅ UNIQUE constraint enforcement tested

Unblocks

Tasks #21-25, #38, #51-56, #75-76 (Accounts commands/queries/UI, hoster plugins)


Summary by cubic

Adds SQLite-backed Accounts persistence and a driven AccountRepository to implement task 20. Refactors Account to use AccountId(String), adds traffic_total, last_validated, created_at, and hardens credential refs and value validation; credentials remain in the OS keyring.

  • New Features

    • New accounts table with UNIQUE (service_name, username) and deterministic ordering by created_at (upsert preserves created_at).
    • AccountRepository port and SqliteAccountRepo adapter with find_by_id, save (upsert), list, list_by_service, delete; UNIQUE violations map to AlreadyExists.
    • sea-orm entity with from_domain/into_domain converters; i64 conversions are checked (ValidationError if above i64::MAX).
    • Domain updates: AccountId(String), new fields, and credential_ref() → percent-encoded keyring://{service}/{username} (no credentials in SQLite).
  • Migration

    • Run DB migrations to create accounts and indexes; existing data is preserved.
    • Update call sites to construct AccountId and provide created_at where needed.
    • Ensure an OS keyring is available; passwords/tokens are not stored in SQLite.

Written for commit 6734ee6. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features

    • Persistent account storage with uniqueness per (service, username), service-scoped listing, and deterministic ordering.
    • Credentials referenced via the OS keychain (not stored in DB).
    • Account metadata expanded with traffic totals, validation timestamps, and creation timestamps.
  • Tests

    • Extensive tests for migrations, persistence behavior, uniqueness constraints, ordering, deletion, and data conversions.

Add `accounts` table migration with UNIQUE(service_name, username),
sea-orm entity + converters, `AccountRepository` driven port and
`SqliteAccountRepo` adapter. Refactor domain `Account` to a `String`-backed
`AccountId` matching the spec's TEXT PRIMARY KEY and add the
`traffic_total` / `last_validated` / `created_at` fields required by
PRD-v2 §8 P1. Credentials stay out of SQLite -- exposed via
`Account::credential_ref()` returning `keyring://{service}/{username}`.

Unblocks tasks 21-25, 38, 51-56, 75-76 (Accounts commands/queries/UI,
Debrid/premium hoster plugins).
@github-actions github-actions Bot added documentation Improvements or additions to documentation rust labels Apr 28, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 33ddb49a-803c-40e5-b852-5b9181a10fbe

📥 Commits

Reviewing files that changed from the base of the PR and between cfaf38f and 6734ee6.

📒 Files selected for processing (1)
  • src-tauri/src/domain/ports/driven/tests.rs

📝 Walkthrough

Walkthrough

This PR adds SQLite-backed account persistence: a new accounts table with UNIQUE(service_name, username), SeaORM entity + migration, an AccountRepository port, a SqliteAccountRepo adapter with upsert/delete/list semantics, domain model updates (AccountId, traffic/validation/created timestamps, credential_ref keyring URI), and tests.

Changes

Cohort / File(s) Summary
Domain Model
src-tauri/src/domain/model/account.rs, src-tauri/src/domain/model/mod.rs
Introduces AccountId newtype; adds traffic_total, last_validated, non-optional created_at; reconstruct constructor; accessors/setters; credential_ref() producing keyring://{service}/{username}; AccountType Display/FromStr.
Driven Port (AccountRepository)
src-tauri/src/domain/ports/driven/account_repository.rs, src-tauri/src/domain/ports/driven/mod.rs, src-tauri/src/domain/ports/driven/tests.rs
Adds AccountRepository trait (find_by_id, save, list, list_by_service, delete) and an in-memory test implementation enforcing uniqueness, preserving created_at, and deterministic ordering.
SQLite Entity Converters
src-tauri/src/adapters/driven/sqlite/entities/account.rs, src-tauri/src/adapters/driven/sqlite/entities/mod.rs
Adds SeaORM accounts entity Model and converters: into_domain() and ActiveModel::from_domain() with enabled mapping, optional u64 ↔ i64 conversion, and overflow validation returning DomainError::ValidationError.
SQLite Migration
src-tauri/src/adapters/driven/sqlite/migrations/m20260428_000006_create_accounts.rs, .../migrations/mod.rs
Adds migration creating Accounts table (text Id PK, ServiceName, Username, AccountType, Enabled, nullable bigints, CreatedAt) with composite UNIQUE(ServiceName, Username) and service index; registers migration.
SqliteAccountRepo + Tests
src-tauri/src/adapters/driven/sqlite/account_repo.rs
Adds SqliteAccountRepo implementing AccountRepository; synchronous wrappers over SeaORM async via block_on; upsert preserves created_at; maps SQLite UNIQUE violations to DomainError::AlreadyExists; deterministic listing by (created_at, id); extensive tests including multiple UNIQUE error message variants.
SQLite Adapter & Connection Tests
src-tauri/src/adapters/driven/sqlite/mod.rs, src-tauri/src/adapters/driven/sqlite/connection.rs
Exports account_repo module; extends migration/connection tests to assert accounts table creation, partial-migration application, and UNIQUE(service_name, username) constraint behavior.
Crate Export & Changelog
src-tauri/src/lib.rs, CHANGELOG.md
Re-exports SqliteAccountRepo at crate root; documents accounts persistence, schema, keyring credential reference, and roadmap unblocking.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant Port as AccountRepository
    participant Adapter as SqliteAccountRepo
    participant DB as SQLite
    participant Keyring as OS_Keyring

    App->>Port: save(account)
    Port->>Adapter: save(account)
    Adapter->>DB: INSERT ... ON CONFLICT(...) DO UPDATE ...
    DB-->>Adapter: OK / UNIQUE VIOLATION
    alt UNIQUE VIOLATION
        Adapter-->>Port: Err(DomainError::AlreadyExists)
        Port-->>App: Err(DomainError::AlreadyExists)
    else Success
        Adapter-->>Port: Ok(())
        Port-->>App: Ok(())
    end

    App->>Port: find_by_id(id)
    Port->>Adapter: find_by_id(id)
    Adapter->>DB: SELECT ... WHERE id=?
    DB-->>Adapter: row / none
    alt row
        Adapter->>Port: Some(Account)
        Port-->>App: Some(Account)
    else none
        Port-->>App: None
    end

    App->>Account: credential_ref()
    Account-->>App: "keyring://{service}/{username}"
    App->>Keyring: lookup("keyring://...")
    Keyring-->>App: credentials
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hop through schema, seeds in my paw,
Unique pairs guarded by rule and by law,
Timestamps and traffic tucked tidy and neat,
Secrets sleep safe where keyrings keep beat,
I nibble a byte — persistence is sweet!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(account): SQLite Accounts repository (task 20)' accurately describes the main change—implementing a SQLite-backed AccountRepository adapter. It is specific, concise, and clearly conveys the primary purpose of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/task-20-repo-accounts

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src-tauri/src/adapters/driven/sqlite/connection.rs (1)

167-176: Assert duplicate failure reason, not only failure presence.

Line 175 currently accepts any insert error. Tightening this to validate uniqueness-related failure will prevent false positives.

Proposed test hardening
-        let dup = db
+        let dup = db
             .execute(Statement::from_string(
                 sea_orm::DatabaseBackend::Sqlite,
                 format!(
                     "INSERT INTO accounts (id, service_name, username, account_type, enabled, created_at) VALUES ('a2', 'real-debrid', 'alice', 'debrid', 1, {now})"
                 ),
             ))
             .await;
-        assert!(dup.is_err(), "UNIQUE(service_name, username) must reject");
+        let dup_err = dup.expect_err("UNIQUE(service_name, username) must reject");
+        assert!(
+            dup_err.to_string().to_ascii_lowercase().contains("unique"),
+            "expected UNIQUE constraint error, got: {dup_err}"
+        );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/adapters/driven/sqlite/connection.rs` around lines 167 - 176,
The test currently only asserts that db.execute(...) (captured as dup) is an
Err; change it to inspect the DbErr returned by
db.execute/Statement::from_string and assert it indicates a uniqueness
constraint violation (e.g., match DbErr::Exec or the underlying
Sqlx/DatabaseError and check the error message contains "UNIQUE" or "UNIQUE
constraint failed"). Locate the db.execute call and the assert!(dup.is_err(),
...) and replace the broad assertion with a specific match/assert that
dup.unwrap_err() is the uniqueness-related error from SeaORM/Sqlite.
src-tauri/src/domain/ports/driven/tests.rs (1)

674-690: Add a secondary sort key for deterministic ties.

If multiple accounts share the same created_at, current ordering can vary because source iteration is from HashMap.

Deterministic ordering tweak
     fn list(&self) -> Result<Vec<Account>, DomainError> {
         let mut accounts: Vec<Account> = self.store.lock().unwrap().values().cloned().collect();
-        accounts.sort_by_key(|a| a.created_at());
+        accounts.sort_by(|a, b| {
+            a.created_at()
+                .cmp(&b.created_at())
+                .then_with(|| a.id().as_str().cmp(b.id().as_str()))
+        });
         Ok(accounts)
     }

     fn list_by_service(&self, service_name: &str) -> Result<Vec<Account>, DomainError> {
         let mut accounts: Vec<Account> = self
             .store
             .lock()
             .unwrap()
             .values()
             .filter(|a| a.service_name() == service_name)
             .cloned()
             .collect();
-        accounts.sort_by_key(|a| a.created_at());
+        accounts.sort_by(|a, b| {
+            a.created_at()
+                .cmp(&b.created_at())
+                .then_with(|| a.id().as_str().cmp(b.id().as_str()))
+        });
         Ok(accounts)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/domain/ports/driven/tests.rs` around lines 674 - 690, The
current sort in list and list_by_service uses only created_at, which yields
non-deterministic order for ties; update both functions (list and
list_by_service) to sort with a secondary key (e.g., account id or another
stable field on Account) so tied created_at values are ordered
deterministically—modify the sort_by_key call to use a tuple or comparator that
includes created_at and the stable field (reference Account::created_at and the
stable identifier accessor such as Account::id or Account::service_name).
src-tauri/src/adapters/driven/sqlite/account_repo.rs (1)

48-60: Keep created_at insert-only on the upsert path.

Updating CreatedAt here means any later save can rewrite the original creation timestamp and change list ordering. I'd leave it out of update_columns(...) so it remains stable after the first insert.

Diff
                     account::Column::TrafficTotal,
                     account::Column::ValidUntil,
                     account::Column::LastValidated,
-                    account::Column::CreatedAt,
                 ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/adapters/driven/sqlite/account_repo.rs` around lines 48 - 60,
The upsert currently includes account::Column::CreatedAt in the update_columns
array which allows later saves to overwrite creation timestamps; remove
account::Column::CreatedAt from the
OnConflict::column(account::Column::Id).update_columns(...) list so created_at
is only set on insert and not updated on conflict, leaving the other columns
(ServiceName, Username, AccountType, Enabled, TrafficLeft, TrafficTotal,
ValidUntil, LastValidated) unchanged in behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src-tauri/src/adapters/driven/sqlite/entities/account.rs`:
- Around line 47-63: The ActiveModel::from_domain function currently uses
unchecked `as i64` casts for u64 fields which can overflow; replace those casts
for traffic_left, traffic_total, valid_until, last_validated, and created_at
with checked conversions (i64::try_from(...)) and surface failures instead of
silently producing negative values—change from_domain to return a Result<Self,
E> (or propagate a conversion error type) and use
i64::try_from(account.traffic_left().map(|b| b as
u64).unwrap_or_default())-style calls (or map each Option<u64> with .map(|v|
i64::try_from(v)?)) so each Set(...) receives a validated i64 or the function
returns an error; ensure you update callers to handle the Result.

In `@src-tauri/src/domain/model/account.rs`:
- Around line 163-166: The credential_ref() method builds a fragile URI from
service_name and username; percent-encode each path segment (service_name and
username) before formatting to avoid collisions when they contain reserved
characters (or alternatively validate and reject unsupported chars). Update
credential_ref() to percent-encode self.service_name and self.username (e.g.,
using the percent-encoding / url crate’s component encoding for path segments)
and then return format!("keyring://{}/{}", encoded_service, encoded_username) so
stored refs are unambiguous.

---

Nitpick comments:
In `@src-tauri/src/adapters/driven/sqlite/account_repo.rs`:
- Around line 48-60: The upsert currently includes account::Column::CreatedAt in
the update_columns array which allows later saves to overwrite creation
timestamps; remove account::Column::CreatedAt from the
OnConflict::column(account::Column::Id).update_columns(...) list so created_at
is only set on insert and not updated on conflict, leaving the other columns
(ServiceName, Username, AccountType, Enabled, TrafficLeft, TrafficTotal,
ValidUntil, LastValidated) unchanged in behavior.

In `@src-tauri/src/adapters/driven/sqlite/connection.rs`:
- Around line 167-176: The test currently only asserts that db.execute(...)
(captured as dup) is an Err; change it to inspect the DbErr returned by
db.execute/Statement::from_string and assert it indicates a uniqueness
constraint violation (e.g., match DbErr::Exec or the underlying
Sqlx/DatabaseError and check the error message contains "UNIQUE" or "UNIQUE
constraint failed"). Locate the db.execute call and the assert!(dup.is_err(),
...) and replace the broad assertion with a specific match/assert that
dup.unwrap_err() is the uniqueness-related error from SeaORM/Sqlite.

In `@src-tauri/src/domain/ports/driven/tests.rs`:
- Around line 674-690: The current sort in list and list_by_service uses only
created_at, which yields non-deterministic order for ties; update both functions
(list and list_by_service) to sort with a secondary key (e.g., account id or
another stable field on Account) so tied created_at values are ordered
deterministically—modify the sort_by_key call to use a tuple or comparator that
includes created_at and the stable field (reference Account::created_at and the
stable identifier accessor such as Account::id or Account::service_name).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ae9c04ff-8fd9-4adf-bdb8-1ffe577dc7cc

📥 Commits

Reviewing files that changed from the base of the PR and between 0b66225 and 4b7b7c0.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • src-tauri/src/adapters/driven/sqlite/account_repo.rs
  • src-tauri/src/adapters/driven/sqlite/connection.rs
  • src-tauri/src/adapters/driven/sqlite/entities/account.rs
  • src-tauri/src/adapters/driven/sqlite/entities/mod.rs
  • src-tauri/src/adapters/driven/sqlite/migrations/m20260428_000006_create_accounts.rs
  • src-tauri/src/adapters/driven/sqlite/migrations/mod.rs
  • src-tauri/src/adapters/driven/sqlite/mod.rs
  • src-tauri/src/domain/model/account.rs
  • src-tauri/src/domain/model/mod.rs
  • src-tauri/src/domain/ports/driven/account_repository.rs
  • src-tauri/src/domain/ports/driven/mod.rs
  • src-tauri/src/domain/ports/driven/tests.rs
  • src-tauri/src/lib.rs

Comment thread src-tauri/src/adapters/driven/sqlite/entities/account.rs
Comment thread src-tauri/src/domain/model/account.rs Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 14 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src-tauri/src/adapters/driven/sqlite/account_repo.rs">

<violation number="1" location="src-tauri/src/adapters/driven/sqlite/account_repo.rs:59">
P2: `created_at` is being updated during upsert. This should remain immutable after initial insert, otherwise subsequent saves can rewrite creation time and reorder list results.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread src-tauri/src/adapters/driven/sqlite/account_repo.rs Outdated
- Replace unchecked `as i64` casts with `i64::try_from(...)` in
  `ActiveModel::from_domain` so u64 values above i64::MAX surface
  as `ValidationError` instead of silently flipping to negatives.
- Percent-encode service_name and username in
  `Account::credential_ref()` so reserved characters (`/`, `@`,
  unicode...) cannot produce ambiguous keyring refs that point at
  the wrong stored credential.
- Drop `CreatedAt` from the upsert `update_columns` list so the
  original insertion timestamp stays stable across subsequent
  saves, preventing list-order drift.
- Tighten the connection-level UNIQUE test to assert the error
  message actually mentions \"unique\" rather than accepting any
  failure.
- Sort `InMemoryAccountRepository::list*` deterministically by
  (created_at, id) to match the SQLite adapter and avoid HashMap
  iteration order leaking through ties.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src-tauri/src/domain/ports/driven/tests.rs`:
- Around line 655-671: The mock save method currently overwrites an existing
Account (in save) including its created_at, causing behavior divergence from the
SQLite adapter; change save so that when guard.get(account.id()) finds an
existing entry you preserve its created_at value and copy or set that timestamp
onto the Account being stored (or construct a new Account merging
existing.created_at with the incoming account's other fields) before inserting
into guard, keeping the collision check logic and using the same AccountId,
mutex guard and Account methods (service_name, username, id, created_at) to
locate and preserve the original creation time.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e096de42-7ef0-4832-9241-a97b8117b052

📥 Commits

Reviewing files that changed from the base of the PR and between 4b7b7c0 and cfaf38f.

📒 Files selected for processing (5)
  • src-tauri/src/adapters/driven/sqlite/account_repo.rs
  • src-tauri/src/adapters/driven/sqlite/connection.rs
  • src-tauri/src/adapters/driven/sqlite/entities/account.rs
  • src-tauri/src/domain/model/account.rs
  • src-tauri/src/domain/ports/driven/tests.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src-tauri/src/adapters/driven/sqlite/connection.rs
  • src-tauri/src/adapters/driven/sqlite/entities/account.rs

Comment thread src-tauri/src/domain/ports/driven/tests.rs
Re-saving the same id in the in-memory mock previously overwrote
`created_at`, which diverged from the SQLite adapter's insert-only
semantics. The mock now mirrors production: same-id saves keep the
existing timestamp and only update mutable fields.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant