Skip to content

feat(server-auth): waveflow-server account binding (Phase 1.f.desktop.1)#189

Merged
InstaZDLL merged 2 commits into
mainfrom
feat/1-f-desktop-1-server-auth
May 31, 2026
Merged

feat(server-auth): waveflow-server account binding (Phase 1.f.desktop.1)#189
InstaZDLL merged 2 commits into
mainfrom
feat/1-f-desktop-1-server-auth

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 31, 2026

Foundational desktop wiring for the waveflow-server sync surface β€” Phase 1.f.desktop.1 of #133. Closes the auth gap so the next three sub-PRs can land on top:

  • 1.f.desktop.2 β€” Lamport clock + pending-ops queue
  • 1.f.desktop.3 β€” Settings "Mode serveur" toggle + repo swap
  • 1.f.desktop.4 β€” WebSocket subscribe + apply remote ops

This PR ships only the auth + HTTP-client foundation. No sync code paths consume WaveflowServerClient yet β€” it's marked #[allow(dead_code)] with a comment explaining the next consumer.

Summary

  • New auth_credential provider 'waveflow_server' via a profile migration (rebuild-table pattern matching add_spotify_auth_provider).
  • src-tauri/crates/app/src/server_client.rs β€” URL / JWT persistence + WaveflowServerClient (reqwest wrapper with Bearer auto-attach). Single source of truth for the server binding; documented surface for the upcoming sync sub-PRs.
  • Five #[tauri::command] entries in commands/server_auth.rs: server_get_status, server_set_url, server_set_token, server_sign_out, server_open_login_browser. Every mutating command returns a fresh ServerStatus so the UI re-syncs on the same round-trip.
  • New Settings β†’ IntΓ©grations card (ServerAccountCard.tsx) β€” URL input + JWT paste textarea + "Open login" button + emerald "Signed in" badge + Sign out. The cancelled-flag pattern on initial fetch keeps the React hooks v7 set-state-in-effect rule happy without lifting state.
  • i18n: settings.serverAccount.* keys propagated to all 17 locales per the CLAUDE.md convention. Brand tokens (WaveFlow, JWT, URL, Better Auth) verbatim across locales.

Sign-in flow

Today: manual paste. The user clicks "Ouvrir la connexion", their default browser opens the configured server URL, they sign in via Better Auth (waveflow-web), copy the issued JWT, and paste it back. Works against the current waveflow-web deploy without any web-side change.

Follow-up 1.f.desktop.1b: replace the paste step with a local-loopback OAuth listener (mirroring commands::spotify's tiny_http pattern). That requires a small companion PR on waveflow-web to add a /desktop-login?cb=… route that mints the JWT via auth.api.getToken and redirects to the localhost callback. Tracked separately so this PR stays focused.

Out of scope (deferred, documented in module docstring)

  • OS-keyring storage β€” the keyring crate ships fine on macOS / Windows; the Linux story relies on libsecret + a running secret service, which I want to validate against an AppImage build before committing to it. JWT lives in the per-profile auth_credential.token_encrypted blob today (same storage shape as Last.fm, ListenBrainz, Spotify).
  • JWT refresh on 401 β€” depends on a Better Auth refresh-token endpoint we haven't surfaced on waveflow-web yet. 1.f.desktop.4 adds this once the server-fn lands.

Test plan

  • `bun run typecheck` clean
  • `bun run lint` clean
  • `cargo check --manifest-path src-tauri/Cargo.toml --workspace` clean
  • `cargo clippy --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings` clean
  • Manual smoke against a local `waveflow-server`:
    • Open Settings β†’ IntΓ©grations β†’ Compte serveur WaveFlow
    • Paste `http://127.0.0.1:3000\` in URL, save β†’ emerald badge stays hidden ("signed_in: false")
    • Click "Ouvrir la connexion" β†’ system browser opens `http://127.0.0.1:3000\`
    • Sign in there, copy JWT, paste into Token field, save β†’ emerald "Signed in" badge appears
    • Switch profile β†’ JWT is per-profile, badge disappears on the second profile
    • Click "Se dΓ©connecter" β†’ badge disappears, URL stays
    • Type a malformed URL (`htp://x`) β†’ red error message via the validation path

Summary by CodeRabbit

  • New Features

    • Configuration d’un compte serveur dans les ParamΓ¨tres (carte dΓ©diΓ©e) avec affichage du statut (connectΓ©/non).
    • Sauvegarde et gestion de l’URL du serveur et du token JWT.
    • Ouverture du flux d’authentification dans le navigateur pour faciliter la connexion.
    • DΓ©connexion (sign out) depuis l’interface.
  • Chores

    • Localisations ajoutΓ©es pour la gestion du compte serveur (plusieurs langues).
    • Migration de base de donnΓ©es pour supporter le fournisseur d’authentification serveur.

Foundational desktop wiring for the waveflow-server sync surface. Every
later sub-PR in 1.f.desktop.* reads its config through the module added
here.

* **Schema** β€” new migration extends the `auth_credential` provider
  CHECK to include `'waveflow_server'`, mirroring the existing
  Last.fm / Spotify rebuild pattern. Per-profile by design: each
  desktop profile maps to one Better Auth account, so a profile
  switch swaps the server identity along with the local library.
* **`src/server_client.rs`** β€” three responsibilities:
  - read/write of `app_setting['app.waveflow_server_url']` (app-wide
    base URL; validates parseability + http(s) scheme)
  - read/write/clear of the per-profile JWT, with a structural sanity
    check on three dot-separated segments before persistence
  - `WaveflowServerClient::try_build` that returns an HTTP client
    pre-baked with the base URL + Bearer header. Currently
    `#[allow(dead_code)]` β€” sync (`1.f.desktop.2`+) is the first
    consumer, kept here so the foundational PR ships the complete
    auth-header attachment shape for review.
* **`commands/server_auth.rs`** β€” five `#[tauri::command]` entries:
  `server_get_status`, `server_set_url`, `server_set_token`,
  `server_sign_out`, `server_open_login_browser`. Every mutating
  command returns a `ServerStatus` so the UI re-syncs on the same
  round-trip.
* **Settings β†’ IntΓ©grations β†’ "Compte serveur WaveFlow"** β€” new card
  with two inputs (server URL + JWT paste), an "Open login" button
  that opens the configured URL in the default browser, an
  emerald-tinted "Signed in" badge and a sign-out button. The
  cancelled-flag pattern on the initial fetch keeps the React hooks
  v7 `set-state-in-effect` rule happy.
* **i18n** β€” `settings.serverAccount.*` keys propagated to all 17
  locales (`ar de en es fr hi id it ja ko nl pt pt-BR ru tr zh-CN zh-TW`)
  per the convention in CLAUDE.md. Brand tokens (WaveFlow, JWT, URL,
  Better Auth) stay verbatim across locales.

Sign-in flow today is **manual paste**: the user opens the server URL
in their browser, signs in via Better Auth, copies the issued JWT and
pastes it back into the card. A polished local-loopback OAuth flow
(mirroring `commands::spotify`'s tiny_http listener) ships in a
follow-up `1.f.desktop.1b` PR once the matching `/desktop-login` route
lands on `waveflow-web`.

OS-keyring storage and JWT refresh on 401 are explicitly out of scope
β€” documented in the `server_client` module docstring as 1.f.desktop.1b
/ 1.f.desktop.4 work.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL InstaZDLL added scope: frontend React/Vite frontend (src/) scope: backend Rust/Tauri backend (src-tauri/) scope: i18n Translations (src/i18n/) labels May 31, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Review Change Stack

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: eaef36b9-9afd-49a1-a6be-0cc717e17adf

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 6d6e544 and 1cb0216.

πŸ“’ Files selected for processing (4)
  • src-tauri/crates/app/src/commands/player.rs
  • src-tauri/crates/core/src/domain/profile.rs
  • src-tauri/crates/core/src/repository/postgres/profile.rs
  • src/components/views/settings/ServerAccountCard.tsx

πŸ“ Walkthrough

Walkthrough

Cette PR ajoute une interface de configuration pour lier un compte serveur WaveFlow au profil desktop, exposant cinq commandes Tauri pour lire/écrire l'URL du serveur et le token JWT, avec persistance en base de données, validation stricte et un composant React intégré dans la section Integrations des Paramètres.

Changes

Server Account Configuration

Layer / File(s) Summary
Backend server persistence layer
src-tauri/crates/app/src/server_client.rs
ServerStatus (url optionnelle + signed_in), fonctions async pour read/write URL via app_setting avec validation http/https, read/write/clear JWT in auth_credential with structural JWT check, status() aggregator, and WaveflowServerClient that builds reqwest client and injects Authorization: Bearer on requests.
Backend Tauri command handlers and wiring
src-tauri/crates/app/src/commands/server_auth.rs, src-tauri/crates/app/src/commands/mod.rs, src-tauri/crates/app/src/lib.rs
Five async commands (server_get_status, server_set_url, server_set_token, server_sign_out, server_open_login_browser) delegating to server_client and registered in tauri::generate_handler! for frontend invocation.
Database schema
src-tauri/migrations/profile/20260601000000_waveflow_server_auth_provider.sql
Migration adds waveflow_server to provider CHECK in auth_credential by table rebuild (disable foreign_keys, copy, rename, re-enable).
Frontend Tauri API wrapper
src/lib/tauri/serverAuth.ts
Exports ServerStatus and functions serverGetStatus, serverSetUrl, serverSetToken, serverSignOut, serverOpenLoginBrowser that call native commands via invoke.
Frontend UI component
src/components/views/settings/ServerAccountCard.tsx
React component loads server status on mount, manages urlDraft/tokenDraft, wires Tauri actions with unified busy and error handling, conditionally enables Open login / Sign out based on status.
Settings integration
src/components/views/SettingsView.tsx
Imports and renders ServerAccountCard in Integrations tab above sync surfaces.
Internationalization
src/i18n/locales/{ar,de,en,es,fr,hi,id,it,ja,ko,nl,pt-BR,pt,ru,tr,zh-CN,zh-TW}.json
Adds settings.serverAccount translation keys (title, subtitle, signedIn, url/token labels/placeholders/hints, actions openLogin/signOut) across locales.
Misc formatting
src-tauri/crates/app/src/commands/player.rs, src-tauri/crates/core/src/domain/profile.rs, src-tauri/crates/core/src/repository/postgres/profile.rs
Small non-functional formatting changes and a simplified error message formatting.

Sequence Diagram

sequenceDiagram
  participant User
  participant ServerAccountCard as ServerAccountCard (React)
  participant serverAuthWrapper as serverAuth (TS)
  participant Tauri as Tauri IPC
  participant server_auth as server_auth (Rust commands)
  participant server_client as server_client (persistence)
  User->>ServerAccountCard: Enter URL & save
  ServerAccountCard->>serverAuthWrapper: serverSetUrl(url)
  serverAuthWrapper->>Tauri: invoke("server_set_url", {url})
  Tauri->>server_auth: server_set_url
  server_auth->>server_client: write_url + status
  server_client->>server_auth: ServerStatus
  server_auth->>Tauri: return ServerStatus
  Tauri->>serverAuthWrapper: ServerStatus
  serverAuthWrapper->>ServerAccountCard: Promise<ServerStatus>
  ServerAccountCard->>User: Display updated status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • InstaZDLL/WaveFlow#83: Refactoring de SettingsView pour monter/dΓ©monter les sections selon l'onglet actif, directement liΓ© au rendu du composant ServerAccountCard dans la section Integrations.

Suggested labels

scope: docs

Poem

πŸ” URL et JWT en place, la liaison est prΓͺte,
Tauri murmure routes et promesses en tΓͺte,
Le serveur se révèle, l'interface s'active,
Dix-sept langues disent «connecté», vive la dérive!

πŸš₯ Pre-merge checks | βœ… 5
βœ… Passed checks (5 passed)
Check name Status Explanation
Title check βœ… Passed Le titre suit la convention Conventional Commits avec scope kebab-case et dΓ©crit clairement la principale addition : intΓ©gration d'authentification waveflow-server.
Description check βœ… Passed La description couvre les Γ©lΓ©ments clΓ©s : rΓ©sumΓ© de la change, plan de test, blocages dΓ©fΓ©rΓ©s documentΓ©s. Toutes les sections importantes du template sont remplies avec dΓ©tail.
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/1-f-desktop-1-server-auth

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

@InstaZDLL InstaZDLL added type: feat New feature size: xl > 500 lines labels May 31, 2026
@InstaZDLL InstaZDLL self-assigned this May 31, 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: 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/components/views/settings/ServerAccountCard.tsx`:
- Around line 59-70: Les appels RPC doivent envoyer les valeurs trimmed: in
handleSaveUrl and the analogous JWT handler (e.g., handleSaveJwt), trim the
input drafts before calling serverSetUrl/serverSetJwt to avoid backend
validation failures from pasted whitespace/newlines; assign const trimmed =
urlDraft.trim() (or jwtDraft.trim()) and use that trimmed variable in the RPC
call and any subsequent state updates (e.g., setStatus), keeping existing
error/busy handling intact.
πŸͺ„ 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: 30706853-5d23-4bcb-9a8c-e20881cb51aa

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 25b9ada and 6d6e544.

πŸ“’ Files selected for processing (25)
  • src-tauri/crates/app/src/commands/mod.rs
  • src-tauri/crates/app/src/commands/server_auth.rs
  • src-tauri/crates/app/src/lib.rs
  • src-tauri/crates/app/src/server_client.rs
  • src-tauri/migrations/profile/20260601000000_waveflow_server_auth_provider.sql
  • src/components/views/SettingsView.tsx
  • src/components/views/settings/ServerAccountCard.tsx
  • src/i18n/locales/ar.json
  • src/i18n/locales/de.json
  • src/i18n/locales/en.json
  • src/i18n/locales/es.json
  • src/i18n/locales/fr.json
  • src/i18n/locales/hi.json
  • src/i18n/locales/id.json
  • src/i18n/locales/it.json
  • src/i18n/locales/ja.json
  • src/i18n/locales/ko.json
  • src/i18n/locales/nl.json
  • src/i18n/locales/pt-BR.json
  • src/i18n/locales/pt.json
  • src/i18n/locales/ru.json
  • src/i18n/locales/tr.json
  • src/i18n/locales/zh-CN.json
  • src/i18n/locales/zh-TW.json
  • src/lib/tauri/serverAuth.ts

Comment thread src/components/views/settings/ServerAccountCard.tsx
@coderabbitai on PR #189 flagged that the handlers were sending the
raw draft strings. The backend already trims, so this isn't a
functional bug β€” but reflecting the normalised value in the input
keeps the visible draft in sync with the persisted row without a
refresh round-trip, and trimming the JWT pre-send guards against a
trailing newline from a browser copy-paste tripping the
three-segment structural check.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL InstaZDLL merged commit f33a2ec into main May 31, 2026
14 checks passed
@InstaZDLL InstaZDLL deleted the feat/1-f-desktop-1-server-auth branch May 31, 2026 14:19
InstaZDLL added a commit that referenced this pull request May 31, 2026
….f.desktop.2) (#191)

* feat(sync): lamport clock + pending-ops queue infrastructure (Phase 1.f.desktop.2)

Local-side foundation for the multi-device sync protocol from
RFC-001 Β§6.6. Every sub-PR downstream (CRUD enqueue hooks
1.f.desktop.2b, Settings toggle + repo swap 1.f.desktop.3, WS
subscriber + drain 1.f.desktop.4) reads the storage shape and the
clock helpers shipped here.

## Schema

- New per-profile migration `20260602000000_sync_pending_op.sql`.
  `sync_pending_op` is the local write-ahead log: BIGSERIAL-style
  `id` for FIFO ordering, UNIQUE on `operation_id` (server
  idempotency key) AND on `lamport_ts` (local defence in depth
  before the server's 23505 hits), CHECK on `op ∈
  {set, delete, insert, noop}`. Retry bookkeeping in
  `last_attempt_at` + `attempt_count` + `last_error`.

## Modules

- `crate::sync::device` β€” stable per-install `device_id` (UUIDv4),
  lazy-generated on first read, persisted app-wide in
  `app_setting['sync.device_id']`. Reinstall = new id (correct
  behaviour; the previous install's Lamport history isn't
  recoverable). App-wide rather than per-profile so two profiles
  sharing a desktop emit ops under the same `device_id` β€” the
  server's `(user_id, device_id, lamport_ts)` UNIQUE already gives
  each profile its own Lamport space via the differing `user_id`.
- `crate::sync::lamport` β€” per-profile monotonic clock.
  `next()` atomically increments + returns the new value via a
  single UPSERT + RETURNING (no SELECT-then-UPDATE race).
  `observe_remote(remote)` bumps the local floor past a remote
  `lamport_ts` so the next local op slots above it. Persisted in
  `profile_setting['sync.lamport_local_max']`.
- `crate::sync::queue` β€” append-only local queue with
  `enqueue` / `list_pending` / `drop_acked` / `mark_failed` /
  `count_pending` / `clear`. `enqueue` assigns the `operation_id`
  itself so nothing leaks across layers; `list_pending` hydrates
  the row into a typed `PendingOp` with parsed JSON payload;
  `drop_acked` batches via `QueryBuilder::push_bind` (sqlx 0.9
  refuses dynamically-built `&str` on the `query()` path).

## Diagnostics

- `commands/sync.rs` exposes `sync_get_queue_state` (returns
  `{ device_id, lamport_local_max, pending_count }`) and
  `sync_clear_pending` (nuclear option for the Settings panel).
  Both registered in `lib.rs`.

## Tests

- 15 unit tests on the helpers, all against in-memory sqlite pools
  with the production schema copy-pasted from the migration:
  - `device`: UUID generation + idempotence on re-read,
    `read` returns None on fresh DB.
  - `lamport`: monotonic increment, observe-remote bumps past
    local, observe-remote refuses to lower (the SQLite type-
    affinity gotcha: `max(int, text)` returns text > int, so
    both sides need a `CAST AS INTEGER` before the scalar `max`
    call), observe-remote handles 0 / negative as no-op, seeds
    clock on fresh profile.
  - `queue`: enqueue writes row + returns ids, FIFO order on
    list_pending, lamport UNIQUE rejects duplicates, drop_acked
    removes specified rows + handles empty input, mark_failed
    bumps counter + records error string, CHECK rejects unknown
    op string, count + clear observable.

## Not in this PR

- CRUD enqueue hooks across `commands/{playlist,library,edit}`.
  ~15 command sites; cleaner to ship in a follow-up
  1.f.desktop.2b so each hook reviews against the helpers
  already in tree.
- Canonical-id mapping for cross-device entity identity. Today
  `entity_id` is the local i64 coerced to TEXT, which is fine
  for ops produced by THIS device. Cross-device requires a
  `local_id ↔ canonical_id` mapping table that 1.f.desktop.4
  introduces alongside the WS subscriber.
- The drain task itself β€” 1.f.desktop.4.

Helpers that aren't called yet (`ensure`, `next`, `observe_remote`,
`enqueue`, `list_pending`, `drop_acked`, `mark_failed`) carry a
module-level `#![allow(dead_code)]` with a justification comment
pointing at the consuming sub-PR. Same pattern PR #189 used for
`WaveflowServerClient`.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

* fix(sync): cr findings on pr #191 β€” log + guard + autoincrement

@coderabbitai surfaced three findings; all valid, all applied with
minimal blast radius:

1. **commands/sync.rs swallowed the `require_profile_pool` error.**
   `Err(_) => (0, 0)` silently rendered "0 pending / 0 lamport" for
   *every* failure mode, not just the legitimate "no active profile"
   path. Now binds `err` and emits a `tracing::warn!` with the cause
   before falling back to defaults β€” operator can correlate the UI
   surface with the real reason.

2. **`queue::list_pending` didn't guard against non-positive limit.**
   SQLite treats `LIMIT -1` as "no limit" and returns the entire
   table β€” a caller miscomputing the page size would pull every
   queued op into memory at once. Added an early return on
   `limit <= 0`. Test `list_pending_guards_against_non_positive_limit`
   pins both edge cases.

3. **`sync_pending_op.id INTEGER PRIMARY KEY` reused ids after
   deletes.** Without `AUTOINCREMENT`, the next insert picks
   `MAX(rowid) + 1` of the surviving rows β€” so once the queue is
   fully drained, the counter restarts at 1. A future drain task
   that tracks a `last_processed_id` high-water mark would silently
   miss every op appended past the reset. Bumped the column to
   `INTEGER PRIMARY KEY AUTOINCREMENT` (cost: one extra
   `sqlite_sequence` write per insert, lost in the JSON-payload
   serialise the same INSERT already does). Test
   `ids_stay_monotonic_after_clear` pins the regression.

Migration safe to bump in place β€” the file is on a feature branch
that hasn't shipped yet, so no production install has applied it.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

---------

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
InstaZDLL added a commit that referenced this pull request Jun 1, 2026
….f.desktop.4a) (#196)

* feat(sync): drain task pushes pending ops to waveflow-server (1.f.desktop.4a)

Reveille the WaveflowServerClient struct that has been dormant since
#189 and pipes the local sync_pending_op queue into POST
/api/v1/sync/ops. One-way push for now; WebSocket subscriber + apply
remote ops + canonical-id mapping all land in a follow-up
1.f.desktop.4b.

## sync::drain

Background task spawned at boot from lib.rs. Loop alternates between
a 30 s periodic tick and a tokio::sync::Notify wake fired by CRUD
command sites after a successful tx.commit() β€” a chatty user's edits
reach the server within ms instead of waiting the full poll interval.

Two gates short-circuit the pass without an HTTP round-trip:

1. WaveflowServerClient::try_build β€” both the server URL and the
   active profile JWT must be configured.
2. SyncMode::Hybrid β€” Local-mode profiles keep their queue local.

Either gate yields DrainOutcome::Skipped.

## Failure semantics

- HTTP 200 β€” drop_acked removes the rows, loop continues to drain any
  newly-arrived ops.
- HTTP 409 lamport_regression β€” lamport::observe_remote bumps the
  local floor past the server's view, mark_failed on the offending
  row, break the loop. The next pass retries with the bumped clock.
- Other HTTP statuses + network errors β€” mark_failed the batch with
  the server reply for diagnostics, break. The periodic poll
  re-attempts later.

## Tauri surface

- New command sync_drain_now for the Settings diagnostics "Push
  now" affordance β€” runs drain_once synchronously and returns the
  DrainOutcome.
- sync_set_mode now notifies the drain task on the Hybrid switch
  so flipping the radio doesn't wait 30 s to fire the first push.

## Boot wiring

- AppState gains drain: Arc<DrainHandle>, default-initialised so
  callers can notify() against it harmlessly before the task spawns.
- lib.rs::run spawns the task right after app.manage(state) so
  app.state::<AppState>() resolves and the same Arc<Notify> is
  shared by command sites + the task.

## Notify-on-commit in playlist commands

All 8 mutation sites in commands/playlist.rs gain a
state.drain.notify() right after tx.commit(). No-op when the
drain task isn't spawned yet (very brief window between
state.manage and drain::spawn).

## Tests

- 25 desktop sync unit tests still green (+3 new in sync::drain
  pinning DrainOutcome's snake_case discriminant tag,
  PushBatchRequest wire shape vs waveflow-server, and
  LamportRegression deserialisation from a server 409 body).

## Out of scope (1.f.desktop.4b)

- WebSocket subscriber + apply remote ops on local SQLite.
- Canonical-id mapping for cross-device entity identity. Today's
  entity_id is the local i64 coerced to TEXT, which is fine for
  the push direction since the server keys ops on (user_id,
  device_id, entity, entity_id) β€” different devices' ops live in
  disjoint namespaces. Cross-device replay needs the mapping
  table the WS subscriber will introduce.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

* fix(sync): serialise drain passes + mark batch failed on 409 parse fail

@coderabbitai surfaced two valid issues on PR #196:

1. drain_once could be called concurrently by the background tick
   and the sync_drain_now Tauri command. Both would read the same
   sync_pending_op batch and POST it twice β€” the server absorbs the
   duplicates via the operation_id UNIQUE, but the wasted round-trip
   + duplicated total_sent accounting is avoidable. Added a shared
   Arc<tokio::sync::Mutex<()>> on AppState (drain_lock). Both
   call sites acquire it before drain_once and hold the guard
   across the await; a concurrent caller waits for the in-flight
   pass to finish rather than racing it.

2. When the 409 body failed to parse as LamportRegression, the loop
   just broke after a tracing::warn. The rows stayed at
   attempt_count=0, so the next pass would hit the same 409 forever
   without any diagnostic trail. Now mark_failed the whole batch
   with the parse error string before breaking, matching the
   shape of the other-status and network-error branches.

25/25 sync tests still green, clippy + fmt clean.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

---------

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
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: frontend React/Vite frontend (src/) scope: i18n Translations (src/i18n/) size: xl > 500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant