Skip to content

feat(core): add postgres feature + PostgresProfileRepository#181

Merged
InstaZDLL merged 4 commits into
mainfrom
feat/core-postgres-feature
May 30, 2026
Merged

feat(core): add postgres feature + PostgresProfileRepository#181
InstaZDLL merged 4 commits into
mainfrom
feat/core-postgres-feature

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 29, 2026

Summary

Phase 1.b.2a β€” give `waveflow-core` a parallel Postgres backend so the upcoming `waveflow-server` (RFC-001 Β§6.5) can consume the same repository traits without rewriting the storage layer.

What's wired

  • New `postgres` Cargo feature mirroring `sqlite`: enables `sqlx/postgres` + `sqlx/runtime-tokio`. The two features are mutually compatible β€” Cargo unifies them if some downstream wants both backends.
  • `PostgresProfileRepository` mirrors the SQLite impl one-for-one: same trait, same semantics, `$N` placeholders, `RETURNING id` instead of `last_insert_rowid()`. The atomic guarded-delete subquery (TOCTOU-free) is preserved as-is since the pattern is portable.
  • Domain types (`Profile`, `Library`, `Playlist`, `Track`, …) derive `sqlx::FromRow` on `any(feature = "sqlite", feature = "postgres")`.
  • Sqlite-specific surfaces (`repository::sqlite`, `scanner::upserts`, smart-playlist materialisers in `custom` / `generator` / `on_repeat`) gated behind `cfg(feature = "sqlite")` so a postgres-only build skips them.
  • `scanner::canonical_name` moved out of `upserts` into its own `canonical` module so the postgres-only build can still use it from `extract`. Re-exported from `upserts` for source compatibility.

Out of scope

  • Postgres implementations for Library / Playlist / Track repositories β€” added incrementally as `waveflow-server` needs them (Phase 1.b.4+).
  • Migration files β€” those live in `waveflow-server/migrations/` (RFC-001 Β§6.5), introduced in the consumer PR.

Test plan

  • `cargo check -p waveflow-core --features sqlite` βœ… (existing default)
  • `cargo check -p waveflow-core --features postgres` βœ… (new path)
  • `cargo check -p waveflow-core --features sqlite,postgres` βœ… (both at once)
  • `cargo check --workspace` βœ… (zero regressions on the desktop app)
  • `cargo clippy --workspace --all-targets -- -D warnings` βœ…
  • `cargo test --workspace` βœ… (66 + 45 = 111 tests pass)

Next

waveflow-server#2 (1.b.2b) consumes this via `waveflow-core = { git, features = ["postgres"] }` and ships the first migration.

Summary by CodeRabbit

Notes de version

  • Nouvelles fonctionnalitΓ©s

    • Support optionnel de PostgreSQL comme backend de donnΓ©es en alternative Γ  SQLite
    • Choix de backend configurable pour les dΓ©ploiements (desktop vs server)
  • Refactorisation

    • RΓ©organisation pour permettre plusieurs backends tout en partageant la logique commune
    • Certaines opΓ©rations spΓ©cifiques (ex. matΓ©rialisation de playlists) restent disponibles uniquement avec SQLite
  • Documentation

    • Architecture mise Γ  jour pour expliquer les options SQLite vs PostgreSQL et leurs implications

Review Change Stack

Phase 1.b.2a (RFC-001 Β§6.5) β€” give waveflow-core a parallel Postgres
backend so waveflow-server (the upcoming HTTP service) can consume the
same repository traits without rewriting the storage layer.

What's wired:
- New `postgres` Cargo feature mirroring `sqlite`: enables
  `sqlx/postgres` + `sqlx/runtime-tokio`. The two features are
  mutually compatible (Cargo unifies them) for the rare case where a
  consumer wants both backends in one build.
- `repository::postgres::PostgresProfileRepository` mirrors the SQLite
  impl one-for-one: same trait, same semantics, `$N` placeholders,
  `RETURNING id` instead of `last_insert_rowid()`. The atomic guarded
  delete (TOCTOU-free) is preserved as-is β€” the subquery pattern is
  portable across both engines.
- Domain types (`Profile`, `Library`, `Playlist`, `Track`, …) derive
  `sqlx::FromRow` on `any(feature = "sqlite", feature = "postgres")`
  so they work with both backends.
- Sqlite-only surfaces (`repository::sqlite`, `scanner::upserts`, the
  smart-playlist materialisers in `smart_playlists::{custom,generator,
  on_repeat}`) are gated behind `cfg(feature = "sqlite")` so a
  postgres-only build skips them. `repository::sqlite` is gated the
  same way for symmetry.
- `scanner::canonical_name` moved out of `upserts` into its own
  `scanner::canonical` module so the postgres-only build can still use
  it from `scanner::extract`. Re-exported from `upserts` for source
  compatibility.

Zero behaviour change for the desktop app β€” it still depends on
`waveflow-core` with `features = ["sqlite"]` and every existing test
passes (66 + 45). Validated cleanly under `--features sqlite`,
`--features postgres`, and `--features sqlite,postgres`.

The actual schema lives in `waveflow-server/migrations/` and is
introduced alongside the consumer in Phase 1.b.2b.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

No actionable comments were generated in the recent review. πŸŽ‰

ℹ️ Recent review info
βš™οΈ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cda479de-d852-46a9-b46b-337c02ac0294

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 02d2430 and 68f894d.

πŸ“’ Files selected for processing (1)
  • src-tauri/crates/core/src/smart_playlists/custom.rs

πŸ“ Walkthrough

Walkthrough

Ajoute la feature Cargo postgres, Γ©tend les derives SQLx aux DTOs, implΓ©mente PostgresProfileRepository (sqlx::PgPool), extrait scanner::canonical::canonical_name, et conditionne le code SQLite-only (scanner upserts, smart-playlists generator/materialisers).

Changes

Support PostgreSQL dans waveflow-core

Layer / File(s) Summary
Feature matrix Cargo et documentation backend
src-tauri/crates/core/Cargo.toml
Ajout de la feature postgres = ["sqlx/postgres", "sqlx/runtime-tokio"] avec documentation expliquant la compatibilitΓ© mutuelle des backends et le partage des traits backend-agnostiques.
DTOs domain avec derives SQLx dual-backend
src-tauri/crates/core/src/domain/{library,playlist,profile,track}.rs
Library, LibraryFolder, Playlist, Profile et TrackRow activent sqlx::FromRow via cfg_attr(any(feature = "sqlite", feature = "postgres"), ...) pour supporter les deux backends SQLite et PostgreSQL.
RΓ©organisation des modules repository
src-tauri/crates/core/src/repository/mod.rs
Ajout du module public track, conditionnement des modules backend sqlite et postgres par leurs features respectives via #[cfg(...)], maintien des modules de traits indΓ©pendants pour dΓ©crire le contrat de persistance.
Module repository Postgres (exports)
src-tauri/crates/core/src/repository/postgres/mod.rs
Ajout du module postgres et rΓ©export de PostgresProfileRepository.
ImplΓ©mentation PostgresProfileRepository
src-tauri/crates/core/src/repository/postgres/{mod,profile}.rs
Implémentation complète du trait ProfileRepository avec list_all, get, insert (RETURNING id), set_data_dir, rename, touch_last_used, suppression gardée (delete_guarded) et helper exists.
Refactoring du scanner pour dual-backend
src-tauri/crates/core/src/scanner/{canonical,mod,extract,upserts}.rs
Extraction de canonical_name dans un nouveau module canonical, conditionnement de upserts par #[cfg(feature = "sqlite")], rΓ©export depuis canonical pour disponibilitΓ© multi-backend, mise Γ  jour des imports.
Conditionnement des APIs smart playlists SQLite-only
src-tauri/crates/core/src/smart_playlists/{custom,mod}.rs
Ajout de #[cfg(feature = "sqlite")] autour des imports SqlitePool, fonctions de construction SQL (order_by_sql, build_node_sql, build_predicate_sql), constantes (HARD_LIMIT) et APIs publiques (materialize, run_query). Types et logique de migration restent non conditionnΓ©s.
Docs d’architecture
CLAUDE.md, docs/architecture/crates.md
Mise Γ  jour des documents pour dΓ©crire la prΓ©sence des features sqlite et postgres, la sΓ©paration des traits/implΓ©mentations, et l'accessibilitΓ© de scanner::canonical hors gate SQLite.

Effort de rΓ©vision estimΓ©

🎯 4 (Complexe) | ⏱️ ~45 minutes

PRs possiblement connexes

  • InstaZDLL/WaveFlow#169: Changements antΓ©rieurs touchant sqlx::FromRow et l'organisation du scanner, liΓ©s Γ  l'extension des derives et au refactor.

Labels suggΓ©rΓ©s

scope: deps, scope: ci

Poème

🎢 PostgreSQL arrive en scène,
SQLite tient sa rengaine,
Traits partagΓ©s, modules triΓ©s,
Canonical nettoie les clΓ©s,
Deux backends, une harmonie.

πŸš₯ Pre-merge checks | βœ… 5
βœ… Passed checks (5 passed)
Check name Status Explanation
Title check βœ… Passed Le titre suit Conventional Commits avec scope kebab-case (core), dΓ©crit clairement l'ajout principal (postgres feature + PostgresProfileRepository) et est concis.
Description check βœ… Passed La description couvre les sections clΓ©s (Summary, What's wired, Out of scope, Test plan, Next) avec dΓ©tails techniques et tests documentΓ©s, respectant la structure attendue.
Docstring Coverage βœ… Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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/core-postgres-feature

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

@InstaZDLL InstaZDLL added scope: backend Rust/Tauri backend (src-tauri/) type: feat New feature size: l 200-500 lines labels May 29, 2026
@InstaZDLL InstaZDLL self-assigned this May 29, 2026
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

πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/crates/core/src/repository/postgres/profile.rs`:
- Around line 107-129: The current delete_guarded implementation can suffer a
race under READ COMMITTED; replace the single statement with a serialized
transaction that takes an explicit lock, e.g. begin a transaction
(self.pool.begin()), acquire a lock on the profile table or the relevant rows
(e.g. execute "LOCK TABLE profile IN SHARE ROW EXCLUSIVE MODE" or SELECT ... FOR
UPDATE on the target row), re-check the COUNT(*) > 1 invariant inside that
transaction and only then perform the DELETE, and finally commit; keep using the
same return logic (ProfileDeleteOutcome::Deleted / WasLast / NotFound) and
reference delete_guarded, exists, and ProfileDeleteOutcome when making the
change; alternatively implement SERIALIZABLE transactions with retry on
serialization failures if you prefer optimistic retry semantics.

In `@src-tauri/crates/core/src/smart_playlists/custom.rs`:
- Around line 347-348: The tests call sql_of(...) which relies on build_node_sql
but build_node_sql is currently gated with #[cfg(feature = "sqlite")], causing
compilation failures when running tests with other feature sets (e.g.,
--features postgres). Make build_node_sql available during testing by changing
its cfg to include test builds (for example #[cfg(any(feature = "sqlite",
test))]) so mod tests can call sql_of(...) without requiring the sqlite feature;
alternatively, gate the test module itself with #[cfg(any(test, feature =
"sqlite"))]β€”refer to the build_node_sql function and the sql_of call in mod
tests to implement this change.
πŸͺ„ 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ccf69907-2406-4133-b4f1-77d198c2dde0

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between d18526f and 75ad434.

πŸ“’ Files selected for processing (14)
  • src-tauri/crates/core/Cargo.toml
  • src-tauri/crates/core/src/domain/library.rs
  • src-tauri/crates/core/src/domain/playlist.rs
  • src-tauri/crates/core/src/domain/profile.rs
  • src-tauri/crates/core/src/domain/track.rs
  • src-tauri/crates/core/src/repository/mod.rs
  • src-tauri/crates/core/src/repository/postgres/mod.rs
  • src-tauri/crates/core/src/repository/postgres/profile.rs
  • src-tauri/crates/core/src/scanner/canonical.rs
  • src-tauri/crates/core/src/scanner/extract.rs
  • src-tauri/crates/core/src/scanner/mod.rs
  • src-tauri/crates/core/src/scanner/upserts.rs
  • src-tauri/crates/core/src/smart_playlists/custom.rs
  • src-tauri/crates/core/src/smart_playlists/mod.rs

Comment thread src-tauri/crates/core/src/repository/postgres/profile.rs
Comment thread src-tauri/crates/core/src/smart_playlists/custom.rs
InstaZDLL added 2 commits May 30, 2026 01:54
1. `PostgresProfileRepository::delete_guarded` had a race under READ
   COMMITTED: two concurrent DELETEs against distinct rows could each
   read `COUNT = 2`, decide they were safe, and both commit β€” emptying
   the table. SQLite is immune because it serialises writers at the
   file level, but Postgres is not. Wrap the check + delete in a
   transaction that acquires `LOCK TABLE profile IN SHARE ROW EXCLUSIVE
   MODE` first; the lock blocks concurrent writers while letting
   SELECTs proceed, so the COUNT(*) re-check observes the same row set
   the DELETE will see.

2. `smart_playlists::custom::tests` calls `build_node_sql`, which is
   now gated on `feature = "sqlite"`, so
   `cargo test -p waveflow-core --no-default-features --features
   postgres` failed to type-check. Gate the test module on the same
   feature so the postgres-only run still compiles. A future
   postgres-specific test module would live alongside under its own
   gate.

Validated:
- cargo check -p waveflow-core (sqlite | postgres | both)
- cargo test -p waveflow-core --no-default-features --features postgres (33 ok)
- cargo test --workspace (66 + 45 ok, zero regression)
- cargo clippy --workspace --all-targets -- -D warnings
PR #181 added the `postgres` feature + `PostgresProfileRepository` to
waveflow-core; the architectural docs still described the feature
matrix as sqlite-only ("a future server will add a postgres feature"),
which is no longer accurate.

- `docs/architecture/crates.md`: feature table now has both rows
  (sqlite + postgres) with the full scope of what each enables
  (`repository::*`, the sqlite-only `scanner::upserts` + smart-playlist
  materialisers, the `FromRow` derives). New note on the
  `scanner::canonical` helper sitting outside both gates for postgres-
  build reuse.
- `docs/architecture/crates.md`: repository-traits bullet now lists
  both implementations as shipped, not "a future postgres/".
- `CLAUDE.md`: backend overview mentions both impls behind their
  matching feature.
@InstaZDLL InstaZDLL added the scope: docs Docs, README, assets label May 29, 2026
@InstaZDLL InstaZDLL enabled auto-merge May 30, 2026 00:02
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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/crates/core/src/smart_playlists/custom.rs`:
- Around line 524-529: The module-level #[cfg(all(test, feature = "sqlite"))]
currently hides tests that validate serialization/migration contracts
(migrate_v1_flat_rules_to_tree and v2_tree_round_trips); instead, restrict only
the SQLite-specific SQL assertion around build_node_sql to #[cfg(feature =
"sqlite")] and keep the serialization/migration tests under #[cfg(test)] so they
run on Postgres-only builds; move or split the SQL-specific assertions into a
helper or separate test annotated with #[cfg(feature = "sqlite")] and ensure
migrate_v1_flat_rules_to_tree and v2_tree_round_trips remain un-gated (or
explicitly #[cfg(test)]).
πŸͺ„ 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d0e9f50d-023e-4c03-9ea3-b30d5e784202

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 75ad434 and 02d2430.

πŸ“’ Files selected for processing (4)
  • CLAUDE.md
  • docs/architecture/crates.md
  • src-tauri/crates/core/src/repository/postgres/profile.rs
  • src-tauri/crates/core/src/smart_playlists/custom.rs

Comment thread src-tauri/crates/core/src/smart_playlists/custom.rs Outdated
PR #181's earlier round gated the whole `custom::tests` module on
`feature = "sqlite"` to keep the SQL-shape assertions compiling, but
that also hid the storage-agnostic serde/migration tests
(`migrate_v1_flat_rules_to_tree`, `v2_tree_round_trips`) β€” running
`cargo test -p waveflow-core --no-default-features --features postgres`
silently skipped them, so the v1 β†’ v2 rule-tree migration contract
wasn't being exercised on the server build.

Split: keep `mod tests` under plain `#[cfg(test)]` so the 2 serde
tests run on both backends; move the 7 SQL-shape tests + the `sql_of`
helper into a nested `sql_tests` module gated on `feature = "sqlite"`.
@InstaZDLL InstaZDLL added size: xl > 500 lines and removed size: l 200-500 lines labels May 30, 2026
@InstaZDLL InstaZDLL merged commit 3ab43f0 into main May 30, 2026
14 checks passed
@InstaZDLL InstaZDLL deleted the feat/core-postgres-feature branch May 30, 2026 00:13
InstaZDLL added a commit that referenced this pull request May 30, 2026
)

Same shape as the profile work in PRs #181 / #183 / #184: server-only
inherent methods that scope every query to both `profile_id` (the
resource's owning profile) and `user_id` (the request's authenticated
user). The single-tenant `LibraryRepository` trait stays untouched on
the desktop side, and `PostgresLibraryRepository` deliberately does
NOT implement it β€” a careless `Box<dyn LibraryRepository>` over the
Postgres backend would otherwise let user A walk user B's libraries.

Methods (5):
- `list_for_profile(profile_id, user_id)` β€” MRU-first, empty list
  when the user doesn't own the profile (no tenancy leak, no auth
  pre-check round-trip)
- `get_for_profile(id, profile_id, user_id)` β€” single row, `None`
  blurs missing / foreign-profile / foreign-user
- `insert_for_profile(draft, profile_id, user_id)` β€” `INSERT ...
  SELECT FROM profile WHERE id = $1 AND user_id = $7`, returns the
  inserted row via `RETURNING *` so the caller skips a follow-up
  SELECT (same race elimination as PR #184's rename_for_user)
- `update_for_profile(id, patch, now_ms, profile_id, user_id)` β€”
  COALESCE partial update, `UPDATE ... RETURNING *` for the same
  reason
- `delete_for_profile(id, profile_id, user_id)` β€” `EXISTS` clause on
  profile validates ownership without a separate join

Every SQL statement encodes the (profile_id, user_id) ownership pair
in its WHERE clause so the storage layer is the single point of
enforcement β€” no convention-by-comment, no handler-discipline gap.

Domain:
- `Library` gains `profile_id: i64` with `#[sqlx(default)]`, mirroring
  `Profile.user_id`. Desktop SELECTs that omit the column (no
  `profile_id` on the per-profile SQLite `library` table) still
  round-trip cleanly via the default. The lone desktop call site
  (`commands/library::create_library`) now sets `profile_id: 0`
  explicitly to match.

Counts (`track_count`, `album_count`, `artist_count`, `genre_count`,
`folder_count`) are stubbed at `0::bigint` in every SELECT for this
phase; they become real aggregates as tracks / albums / playlists
land in 1.b.5b+, without changing the wire shape.

Schema lives in `waveflow-server/migrations/` (next PR):
`library.profile_id BIGINT NOT NULL REFERENCES profile(id) ON
DELETE CASCADE` + the usual indices.

Zero behaviour change on the desktop. Validated: workspace check +
clippy + 111 tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) scope: docs Docs, README, assets size: xl > 500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant