Skip to main content

koan_server/graphql/
mod.rs

1mod helpers;
2mod jobs;
3mod loaders;
4mod mutations;
5mod queries;
6mod server;
7mod subscriptions;
8mod types;
9
10use std::ops::Deref;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
14use std::time::Duration;
15
16use async_graphql::dataloader::DataLoader;
17use async_graphql::{Context, Schema};
18use crossbeam_channel::{Sender, TrySendError};
19use koan_core::audio::viz::VizSnapshot;
20use koan_core::db::connection::Database;
21use koan_core::player::commands::PlayerCommand;
22use koan_core::player::state::{QueueItemId, SharedPlayerState};
23use uuid::Uuid;
24
25use koan_core::auth::Role;
26use loaders::DbLoader;
27use mutations::MutationRoot;
28use queries::QueryRoot;
29pub use server::{
30    ApiServerOpts, cmd_serve, cmd_serve_daemon, execute_in_process, start_api_background,
31};
32use subscriptions::SubscriptionRoot;
33
34use crate::auth::AuthUser;
35
36// ---------------------------------------------------------------------------
37// Connection pool
38// ---------------------------------------------------------------------------
39
40/// How long the dataloader gathers keys before running a batch.
41///
42/// The default 1ms closes the window while a wide selection set is still
43/// registering keys, splitting one query into several.
44const BATCH_WINDOW: Duration = Duration::from_millis(10);
45
46/// How long a resolver waits for a free connection before giving up. Long
47/// enough to ride out a scan chunk, short enough that a wedged writer surfaces
48/// as an error rather than a hung request.
49const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(10);
50
51/// A fixed set of SQLite connections handed out per query, not per field.
52///
53/// `Database::open` runs the DDL batch, the migrations and a WAL checkpoint, so
54/// opening one per resolver field costs tens of statements before the actual
55/// query runs. Connections are opened lazily up to `max` and returned on drop.
56/// Deliberately not a single `Mutex<Connection>`: WAL gives concurrent readers,
57/// and one slow query must not block every other client.
58struct DbPool {
59    path: PathBuf,
60    /// Returned connections wait here. Bounded at `max`, so `try_send` on the
61    /// return path cannot block or fail for lack of room.
62    idle_tx: Sender<Database>,
63    idle_rx: crossbeam_channel::Receiver<Database>,
64    /// Connections in existence (idle plus checked out).
65    live: AtomicUsize,
66    /// Total connections ever opened. Instrumentation only — the fan-out tests
67    /// assert this stays bounded as a query's breadth grows.
68    opens: AtomicUsize,
69    /// Dataloader batches run. Instrumentation only — the N+1 tests assert one
70    /// batch serves a whole selection set.
71    batches: AtomicUsize,
72    /// Whether the schema and migrations have been applied to this path.
73    initialised: AtomicBool,
74    max: usize,
75}
76
77impl DbPool {
78    fn new(path: PathBuf) -> Arc<Self> {
79        // SQLite readers scale with cores; past that they only queue on the OS.
80        let max = std::thread::available_parallelism()
81            .map(|n| n.get())
82            .unwrap_or(4)
83            .clamp(4, 16);
84        let (idle_tx, idle_rx) = crossbeam_channel::bounded(max);
85        Arc::new(Self {
86            path,
87            idle_tx,
88            idle_rx,
89            live: AtomicUsize::new(0),
90            opens: AtomicUsize::new(0),
91            batches: AtomicUsize::new(0),
92            initialised: AtomicBool::new(false),
93            max,
94        })
95    }
96
97    /// The first connection applies the schema; every later one skips it.
98    fn open_new(&self) -> Result<Database, koan_core::db::connection::DbError> {
99        self.opens.fetch_add(1, Ordering::Relaxed);
100        if self.initialised.load(Ordering::Acquire) {
101            Database::open_existing(&self.path)
102        } else {
103            let db = Database::open(&self.path)?;
104            self.initialised.store(true, Ordering::Release);
105            Ok(db)
106        }
107    }
108
109    fn acquire(self: &Arc<Self>) -> async_graphql::Result<PooledDb> {
110        if let Ok(db) = self.idle_rx.try_recv() {
111            return Ok(PooledDb {
112                db: Some(db),
113                pool: self.clone(),
114            });
115        }
116
117        let mut live = self.live.load(Ordering::Relaxed);
118        while live < self.max {
119            match self.live.compare_exchange_weak(
120                live,
121                live + 1,
122                Ordering::AcqRel,
123                Ordering::Relaxed,
124            ) {
125                Ok(_) => {
126                    return match self.open_new() {
127                        Ok(db) => Ok(PooledDb {
128                            db: Some(db),
129                            pool: self.clone(),
130                        }),
131                        Err(e) => {
132                            self.live.fetch_sub(1, Ordering::AcqRel);
133                            Err(internal_error("db open", e))
134                        }
135                    };
136                }
137                Err(actual) => live = actual,
138            }
139        }
140
141        self.idle_rx
142            .recv_timeout(ACQUIRE_TIMEOUT)
143            .map(|db| PooledDb {
144                db: Some(db),
145                pool: self.clone(),
146            })
147            .map_err(|_| async_graphql::Error::new("database busy"))
148    }
149}
150
151/// A connection checked out of the pool, returned when the guard drops.
152struct PooledDb {
153    db: Option<Database>,
154    pool: Arc<DbPool>,
155}
156
157impl Deref for PooledDb {
158    type Target = Database;
159
160    fn deref(&self) -> &Database {
161        self.db.as_ref().expect("connection taken before drop")
162    }
163}
164
165impl Drop for PooledDb {
166    fn drop(&mut self) {
167        if let Some(db) = self.db.take()
168            && let Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) =
169                self.pool.idle_tx.try_send(db)
170        {
171            self.pool.live.fetch_sub(1, Ordering::AcqRel);
172        }
173    }
174}
175
176// ---------------------------------------------------------------------------
177// DB handle wrapper (so we can put it in Context)
178// ---------------------------------------------------------------------------
179
180#[derive(Clone)]
181struct DbHandle {
182    pool: Arc<DbPool>,
183}
184
185impl DbHandle {
186    fn new(path: PathBuf) -> Self {
187        Self {
188            pool: DbPool::new(path),
189        }
190    }
191
192    fn acquire(&self) -> async_graphql::Result<PooledDb> {
193        self.pool.acquire()
194    }
195
196    /// A connection outside the pool, for work that runs for minutes and must
197    /// not deny a connection to request-path resolvers.
198    fn open_detached(&self) -> Result<Database, koan_core::db::connection::DbError> {
199        self.pool.open_new()
200    }
201
202    fn note_batch(&self) {
203        self.pool.batches.fetch_add(1, Ordering::Relaxed);
204    }
205
206    /// Connections opened since the schema was built.
207    #[cfg_attr(not(test), allow(dead_code))]
208    fn open_count(&self) -> usize {
209        self.pool.opens.load(Ordering::Relaxed)
210    }
211
212    /// Dataloader batches run since the schema was built.
213    #[cfg_attr(not(test), allow(dead_code))]
214    fn batch_count(&self) -> usize {
215        self.pool.batches.load(Ordering::Relaxed)
216    }
217}
218
219/// Run a rusqlite closure on the blocking pool with a pooled connection.
220///
221/// rusqlite is blocking start to finish. Calling it inline in an `async fn`
222/// parks a tokio worker for the duration, and enough of those at once starve
223/// the runtime — including the `ReaderStream`s feeding in-flight audio.
224async fn with_db<T, F>(ctx: &Context<'_>, f: F) -> async_graphql::Result<T>
225where
226    F: FnOnce(&Database) -> async_graphql::Result<T> + Send + 'static,
227    T: Send + 'static,
228{
229    let handle = ctx.data::<DbHandle>()?.clone();
230    blocking(move || {
231        let db = handle.acquire()?;
232        f(&db)
233    })
234    .await
235}
236
237/// Run any other blocking work (HTTP fetches, file decoding, tag reads) off the
238/// async workers.
239async fn blocking<T, F>(f: F) -> async_graphql::Result<T>
240where
241    F: FnOnce() -> async_graphql::Result<T> + Send + 'static,
242    T: Send + 'static,
243{
244    tokio::task::spawn_blocking(f)
245        .await
246        .map_err(|e| internal_error("blocking task", e))?
247}
248
249/// Log the detail, return a generic message.
250///
251/// SQLite errors quote the offending statement and filesystem errors quote
252/// absolute paths — a map of the host handed to whoever asked.
253pub(super) fn internal_error(context: &str, e: impl std::fmt::Display) -> async_graphql::Error {
254    log::error!("graphql {}: {}", context, e);
255    async_graphql::Error::new("internal error")
256}
257
258// ---------------------------------------------------------------------------
259// Schema builder
260// ---------------------------------------------------------------------------
261
262pub type KoanSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
263
264pub fn build_schema(
265    state: Arc<SharedPlayerState>,
266    cmd_tx: Sender<PlayerCommand>,
267    db_path: PathBuf,
268    viz: Option<Arc<VizSnapshot>>,
269) -> KoanSchema {
270    build_schema_with(DbHandle::new(db_path), state, cmd_tx, viz)
271}
272
273fn build_schema_with(
274    handle: DbHandle,
275    state: Arc<SharedPlayerState>,
276    cmd_tx: Sender<PlayerCommand>,
277    viz: Option<Arc<VizSnapshot>>,
278) -> KoanSchema {
279    // Batching only, no caching: a schema-wide loader outlives the request, and
280    // favourites and library contents change under it.
281    let loader = DataLoader::new(DbLoader::new(handle.clone()), tokio::spawn).delay(BATCH_WINDOW);
282    loader.enable_all_cache(false);
283
284    let mut builder = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
285        .data(handle)
286        .data(loader)
287        .data(jobs::JobRegistry::default())
288        .data(state)
289        .data(cmd_tx);
290    if let Some(viz) = viz {
291        builder = builder.data(viz);
292    }
293    // A single nested query can otherwise fan out across the whole library and
294    // pin the process for minutes.
295    builder.limit_depth(12).limit_complexity(2000).finish()
296}
297
298// ---------------------------------------------------------------------------
299// Shared helpers used by queries + mutations
300// ---------------------------------------------------------------------------
301
302fn parse_queue_item_id(s: &str) -> async_graphql::Result<QueueItemId> {
303    Uuid::parse_str(s)
304        .map(QueueItemId)
305        .map_err(|e| async_graphql::Error::new(format!("invalid queue item ID '{}': {}", s, e)))
306}
307
308/// How long to wait for room on the player command channel.
309///
310/// The channel is bounded(16) and the player can sit inside `start_playback`
311/// for about a second during a device sample-rate change. A blocking `send`
312/// there parks a tokio worker; this gives the player a moment to drain and
313/// then reports back rather than holding the thread.
314const CMD_SEND_TIMEOUT: Duration = Duration::from_millis(250);
315
316fn send_cmd(ctx: &Context<'_>, cmd: PlayerCommand) -> async_graphql::Result<()> {
317    let tx = ctx.data::<Sender<PlayerCommand>>()?;
318    send_cmd_via(tx, cmd)
319}
320
321fn send_cmd_via(tx: &Sender<PlayerCommand>, cmd: PlayerCommand) -> async_graphql::Result<()> {
322    tx.send_timeout(cmd, CMD_SEND_TIMEOUT)
323        .map_err(|_| async_graphql::Error::new("player busy — command not accepted"))
324}
325
326/// Extract the authenticated user from GraphQL context.
327/// Returns anonymous admin if no user is present (auth disabled or in-process).
328fn get_auth_user(ctx: &Context<'_>) -> AuthUser {
329    ctx.data::<AuthUser>()
330        .cloned()
331        .unwrap_or_else(|_| AuthUser::anonymous_admin())
332}
333
334/// Check that the current user has at least the required role.
335/// Returns an error suitable for GraphQL if the check fails.
336fn require_role(ctx: &Context<'_>, required: Role) -> async_graphql::Result<()> {
337    let user = get_auth_user(ctx);
338    if user.role.has_permission(required) {
339        Ok(())
340    } else {
341        Err(async_graphql::Error::new(format!(
342            "forbidden: requires {} role, you have {}",
343            required, user.role
344        )))
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Tests
350// ---------------------------------------------------------------------------
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use koan_core::db::connection::Database;
356    use koan_core::db::queries;
357    use koan_core::player::commands::CommandChannel;
358    use tempfile::TempDir;
359
360    fn test_schema() -> (
361        KoanSchema,
362        crossbeam_channel::Receiver<PlayerCommand>,
363        TempDir,
364    ) {
365        let tmp = TempDir::new().unwrap();
366        let db_path = tmp.path().join("test.db");
367        let db = Database::open(&db_path).unwrap();
368        koan_core::db::schema::create_tables(&db.conn).unwrap();
369
370        let state = SharedPlayerState::new();
371        let ch = CommandChannel::new();
372        let tx = ch.tx.clone();
373        let rx = ch.rx.clone();
374
375        let schema = build_schema(state, tx, db_path, None);
376        (schema, rx, tmp)
377    }
378
379    /// Same schema, but keeping the `DbHandle` so tests can read its counters.
380    fn instrumented_schema() -> (
381        KoanSchema,
382        DbHandle,
383        crossbeam_channel::Receiver<PlayerCommand>,
384        TempDir,
385    ) {
386        let tmp = TempDir::new().unwrap();
387        let db_path = tmp.path().join("test.db");
388        let db = Database::open(&db_path).unwrap();
389        koan_core::db::schema::create_tables(&db.conn).unwrap();
390
391        let state = SharedPlayerState::new();
392        let ch = CommandChannel::new();
393        let handle = DbHandle::new(db_path);
394        let schema = build_schema_with(handle.clone(), state, ch.tx.clone(), None);
395        (schema, handle, ch.rx.clone(), tmp)
396    }
397
398    /// Seed a library on one connection — the per-track helper opens its own.
399    fn seed_library(db_path: &std::path::Path, artists: usize, albums: usize, tracks: usize) {
400        let db = Database::open(db_path).unwrap();
401        for a in 0..artists {
402            for al in 0..albums {
403                for t in 0..tracks {
404                    let meta = queries::TrackMeta {
405                        title: format!("Track {:03}-{}-{:03}", a, al, t),
406                        artist: format!("Artist {:03}", a),
407                        album_artist: Some(format!("Artist {:03}", a)),
408                        album: format!("Album {:03}-{}", a, al),
409                        track_number: Some(t as i32),
410                        disc: Some(1),
411                        date: Some("2024".into()),
412                        genre: Some("Electronic".into()),
413                        duration_ms: Some(240_000),
414                        path: Some(format!("/tmp/koan-test/{}/{}/{}.flac", a, al, t)),
415                        codec: Some("FLAC".into()),
416                        sample_rate: Some(44100),
417                        bit_depth: Some(16),
418                        channels: Some(2),
419                        bitrate: Some(1411),
420                        size_bytes: Some(42_000_000),
421                        mtime: Some(1700000000),
422                        source: "local".into(),
423                        remote_id: None,
424                        remote_url: None,
425                        album_added_at: None,
426                        label: None,
427                    };
428                    queries::upsert_track(&db.conn, &meta).unwrap();
429                }
430            }
431        }
432    }
433
434    fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
435        let db = Database::open(db_path).unwrap();
436        let meta = queries::TrackMeta {
437            title: title.to_string(),
438            artist: artist.to_string(),
439            album_artist: Some(artist.to_string()),
440            album: album.to_string(),
441            track_number: Some(1),
442            disc: Some(1),
443            date: Some("2024".into()),
444            genre: Some("Electronic".into()),
445            duration_ms: Some(240000),
446            path: Some(format!(
447                "/tmp/test/{}.flac",
448                title.to_lowercase().replace(' ', "_")
449            )),
450            codec: Some("FLAC".into()),
451            sample_rate: Some(44100),
452            bit_depth: Some(16),
453            channels: Some(2),
454            bitrate: Some(1411),
455            size_bytes: Some(42_000_000),
456            mtime: Some(1700000000),
457            source: "local".into(),
458            remote_id: None,
459            remote_url: None,
460            album_added_at: None,
461            label: None,
462        };
463        queries::upsert_track(&db.conn, &meta).unwrap()
464    }
465
466    #[test]
467    fn schema_builds() {
468        let (_schema, _rx, _tmp) = test_schema();
469    }
470
471    #[tokio::test]
472    async fn library_stats_query() {
473        let (schema, _rx, tmp) = test_schema();
474        let db_path = tmp.path().join("test.db");
475        insert_test_track(&db_path, "Track1", "Artist1", "Album1");
476
477        let resp = schema
478            .execute("{ libraryStats { totalTracks totalAlbums totalArtists } }")
479            .await;
480        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
481        let data = resp.data.into_json().unwrap();
482        assert_eq!(data["libraryStats"]["totalTracks"], 1);
483        assert_eq!(data["libraryStats"]["totalAlbums"], 1);
484        assert_eq!(data["libraryStats"]["totalArtists"], 1);
485    }
486
487    #[tokio::test]
488    async fn artists_query() {
489        let (schema, _rx, tmp) = test_schema();
490        let db_path = tmp.path().join("test.db");
491        insert_test_track(&db_path, "T1", "Aphex Twin", "Drukqs");
492        insert_test_track(&db_path, "T2", "Boards of Canada", "MHTRTC");
493
494        let resp = schema
495            .execute("{ artists { edges { node { id name } } } }")
496            .await;
497        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
498        let data = resp.data.into_json().unwrap();
499        let edges = data["artists"]["edges"].as_array().unwrap();
500        assert_eq!(edges.len(), 2);
501    }
502
503    #[tokio::test]
504    async fn tracks_search() {
505        let (schema, _rx, tmp) = test_schema();
506        let db_path = tmp.path().join("test.db");
507        insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
508        insert_test_track(&db_path, "Roygbiv", "Boards of Canada", "MHTRTC");
509
510        let resp = schema
511            .execute(r#"{ tracks(search: "Aphex") { edges { node { id title artist } } } }"#)
512            .await;
513        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
514        let data = resp.data.into_json().unwrap();
515        let edges = data["tracks"]["edges"].as_array().unwrap();
516        assert_eq!(edges.len(), 1);
517        assert_eq!(edges[0]["node"]["title"], "Windowlicker");
518    }
519
520    #[tokio::test]
521    async fn now_playing_stopped() {
522        let (schema, _rx, _tmp) = test_schema();
523        let resp = schema
524            .execute("{ nowPlaying { state positionMs track { title } } }")
525            .await;
526        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
527        let data = resp.data.into_json().unwrap();
528        assert_eq!(data["nowPlaying"]["state"], "STOPPED");
529    }
530
531    #[tokio::test]
532    async fn pause_mutation() {
533        let (schema, rx, _tmp) = test_schema();
534        let resp = schema.execute("mutation { pause { ok message } }").await;
535        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
536        let data = resp.data.into_json().unwrap();
537        assert_eq!(data["pause"]["ok"], true);
538        let cmd = rx.try_recv().unwrap();
539        assert!(matches!(cmd, PlayerCommand::Pause));
540    }
541
542    #[tokio::test]
543    async fn nested_artist_albums_tracks() {
544        let (schema, _rx, tmp) = test_schema();
545        let db_path = tmp.path().join("test.db");
546        insert_test_track(&db_path, "Vordhosbn", "Aphex Twin", "Drukqs");
547        insert_test_track(&db_path, "Avril 14th", "Aphex Twin", "Drukqs");
548
549        let resp = schema
550            .execute(
551                r#"{ artists(search: "Aphex") {
552                    edges { node {
553                        name
554                        albums { edges { node {
555                            title
556                            tracks { edges { node { title } } }
557                        } } }
558                    } }
559                } }"#,
560            )
561            .await;
562        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
563        let data = resp.data.into_json().unwrap();
564        let artist = &data["artists"]["edges"][0]["node"];
565        assert_eq!(artist["name"], "Aphex Twin");
566        let album = &artist["albums"]["edges"][0]["node"];
567        assert_eq!(album["title"], "Drukqs");
568        let tracks = album["tracks"]["edges"].as_array().unwrap();
569        assert_eq!(tracks.len(), 2);
570    }
571
572    #[tokio::test]
573    async fn pagination_has_next() {
574        let (schema, _rx, tmp) = test_schema();
575        let db_path = tmp.path().join("test.db");
576        for i in 0..5 {
577            insert_test_track(
578                &db_path,
579                &format!("Track{}", i),
580                "Artist",
581                &format!("Album{}", i),
582            );
583        }
584
585        let resp = schema
586            .execute(
587                r#"{ artists(first: 1) {
588                    edges { node { name } cursor }
589                    pageInfo { hasNextPage endCursor }
590                } }"#,
591            )
592            .await;
593        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
594        let data = resp.data.into_json().unwrap();
595        // Only 1 artist ("Artist"), so hasNextPage should be false
596        // since all 5 tracks are by the same artist.
597        assert_eq!(data["artists"]["edges"].as_array().unwrap().len(), 1);
598    }
599
600    #[tokio::test]
601    async fn clear_queue_mutation() {
602        let (schema, rx, _tmp) = test_schema();
603        let resp = schema
604            .execute("mutation { clearQueue { ok message } }")
605            .await;
606        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
607        let cmd = rx.try_recv().unwrap();
608        assert!(matches!(cmd, PlayerCommand::ClearPlaylist));
609    }
610
611    #[tokio::test]
612    async fn enqueue_mutation_adds_to_queue() {
613        let (schema, rx, tmp) = test_schema();
614        let db_path = tmp.path().join("test.db");
615
616        // Insert a track into the DB.
617        let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
618
619        // Execute the addToQueue mutation.
620        let query = format!(
621            "mutation {{ addToQueue(trackIds: [{}]) {{ ok message addedCount queueItemIds }} }}",
622            track_id
623        );
624        let resp = schema.execute(&query).await;
625        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
626
627        let data = resp.data.into_json().unwrap();
628        assert_eq!(data["addToQueue"]["ok"], true);
629        assert_eq!(data["addToQueue"]["addedCount"], 1);
630
631        let queue_ids = data["addToQueue"]["queueItemIds"].as_array().unwrap();
632        assert_eq!(queue_ids.len(), 1, "should return one queue item ID");
633
634        // Verify the PlayerCommand was sent through the channel.
635        // The mutation sends AddToPlaylist and then Play (auto-play when stopped).
636        let cmd = rx.try_recv().unwrap();
637        match cmd {
638            PlayerCommand::AddToPlaylist(items) => {
639                assert_eq!(items.len(), 1);
640                assert_eq!(items[0].title, "Windowlicker");
641                assert_eq!(items[0].artist, "Aphex Twin");
642                assert_eq!(items[0].album, "Windowlicker EP");
643            }
644            other => panic!("expected AddToPlaylist, got {:?}", other),
645        }
646
647        // Auto-play command should follow.
648        let play_cmd = rx.try_recv().unwrap();
649        assert!(
650            matches!(play_cmd, PlayerCommand::Play(_)),
651            "expected Play command for auto-play, got {:?}",
652            play_cmd
653        );
654    }
655
656    #[tokio::test]
657    async fn replace_queue_mutation_clears_and_enqueues() {
658        let (schema, rx, tmp) = test_schema();
659        let db_path = tmp.path().join("test.db");
660
661        let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
662        let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
663
664        let query = format!(
665            "mutation {{ replaceQueue(trackIds: [{}, {}]) {{ ok addedCount queueItemIds }} }}",
666            id1, id2
667        );
668        let resp = schema.execute(&query).await;
669        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
670
671        let data = resp.data.into_json().unwrap();
672        assert_eq!(data["replaceQueue"]["addedCount"], 2);
673
674        // One command, not clear-then-add-then-play: three commands down a
675        // bounded channel means the first track starts before the cursor lands
676        // on the one that was asked for.
677        match rx.try_recv().unwrap() {
678            PlayerCommand::ReplacePlaylist { items, start } => {
679                assert_eq!(items.len(), 2);
680                assert_eq!(start, 0, "defaults to the first track");
681            }
682            other => panic!("expected ReplacePlaylist, got {:?}", other),
683        }
684        assert!(rx.try_recv().is_err(), "no follow-up commands");
685    }
686
687    #[tokio::test]
688    async fn replace_queue_starts_where_it_was_asked_to() {
689        let (schema, rx, tmp) = test_schema();
690        let db_path = tmp.path().join("test.db");
691
692        let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
693        let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
694
695        let resp = schema
696            .execute(&format!(
697                "mutation {{ replaceQueue(trackIds: [{}, {}], startAt: 1) {{ ok }} }}",
698                id1, id2
699            ))
700            .await;
701        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
702
703        match rx.try_recv().unwrap() {
704            PlayerCommand::ReplacePlaylist { start, .. } => assert_eq!(start, 1),
705            other => panic!("expected ReplacePlaylist, got {:?}", other),
706        }
707    }
708
709    // ---- Phase 1 tests: queue snapshot, viz, config, playlist version, subscriptions ----
710
711    #[tokio::test]
712    async fn queue_snapshot_has_version_and_status() {
713        let (schema, _rx, _tmp) = test_schema();
714
715        let resp = schema
716            .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
717            .await;
718        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
719        let data = resp.data.into_json().unwrap();
720        // Empty queue.
721        assert_eq!(data["queue"]["version"], 0);
722        assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
723        assert_eq!(data["queue"]["hasPlaying"], false);
724        assert_eq!(data["queue"]["queueCount"], 0);
725    }
726
727    #[tokio::test]
728    async fn queue_entries_have_status_and_download_progress() {
729        use koan_core::player::state::{LoadState, PlaylistItem};
730
731        // Build schema with a shared state we can manipulate directly.
732        let tmp = TempDir::new().unwrap();
733        let db_path = tmp.path().join("test.db");
734        let db = Database::open(&db_path).unwrap();
735        koan_core::db::schema::create_tables(&db.conn).unwrap();
736
737        let state = SharedPlayerState::new();
738        let ch = CommandChannel::new();
739        let schema = build_schema(state.clone(), ch.tx.clone(), db_path, None);
740
741        // Directly add items to the playlist (simulating what the player thread does).
742        let item = PlaylistItem {
743            id: QueueItemId::new(),
744            db_id: None,
745            path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
746            title: "Windowlicker".to_string(),
747            artist: "Aphex Twin".to_string(),
748            album_artist: "Aphex Twin".to_string(),
749            album: "Windowlicker EP".to_string(),
750            year: None,
751            codec: Some("FLAC".to_string()),
752            track_number: Some(1),
753            disc: Some(1),
754            duration_ms: Some(240000),
755            load_state: LoadState::Ready,
756        };
757        state.add_items(vec![item]);
758
759        // Query the queue — should have one entry with QUEUED status.
760        let resp = schema
761            .execute(
762                "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
763            )
764            .await;
765        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
766        let data = resp.data.into_json().unwrap();
767        let entries = data["queue"]["entries"].as_array().unwrap();
768        assert_eq!(entries.len(), 1);
769        assert_eq!(entries[0]["title"], "Windowlicker");
770        // Without a cursor set, all entries are QUEUED.
771        assert_eq!(entries[0]["status"], "QUEUED");
772        assert_eq!(entries[0]["isCurrent"], false);
773        // Local track — no download progress.
774        assert!(entries[0]["downloadProgress"].is_null());
775    }
776
777    #[tokio::test]
778    async fn viz_frame_returns_none_without_viz() {
779        let (schema, _rx, _tmp) = test_schema();
780
781        let resp = schema
782            .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
783            .await;
784        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
785        let data = resp.data.into_json().unwrap();
786        assert!(data["vizFrame"].is_null());
787    }
788
789    #[tokio::test]
790    async fn viz_frame_returns_data_with_viz() {
791        // Build schema with a VizSnapshot.
792        let tmp = TempDir::new().unwrap();
793        let db_path = tmp.path().join("test.db");
794        let db = Database::open(&db_path).unwrap();
795        koan_core::db::schema::create_tables(&db.conn).unwrap();
796
797        let state = SharedPlayerState::new();
798        let ch = CommandChannel::new();
799        let viz = koan_core::audio::viz::VizSnapshot::new();
800
801        // Write some test data.
802        let mut spectrum = [0.0f32; 48];
803        spectrum[0] = 0.75;
804        viz.write(koan_core::audio::viz::VizFrame {
805            spectrum,
806            peaks: [0.0; 48],
807            vu_levels: [0.42, 0.38],
808            beat_energy: 0.6,
809            timestamp: std::time::Instant::now(),
810            waveform: Vec::new(),
811        });
812
813        let schema = build_schema(state, ch.tx.clone(), db_path, Some(viz));
814
815        let resp = schema
816            .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
817            .await;
818        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
819        let data = resp.data.into_json().unwrap();
820        let frame = &data["vizFrame"];
821        assert!(!frame.is_null());
822        let spectrum = frame["spectrum"].as_array().unwrap();
823        assert_eq!(spectrum.len(), 48);
824        assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
825        let vu = frame["vuLevels"].as_array().unwrap();
826        assert_eq!(vu.len(), 2);
827        assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
828        assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
829        // Waveform empty — we didn't request includeWaveform.
830        let waveform = frame["waveform"].as_array().unwrap();
831        assert!(waveform.is_empty());
832    }
833
834    #[tokio::test]
835    async fn config_query() {
836        let (schema, _rx, _tmp) = test_schema();
837
838        let resp = schema
839            .execute(
840                "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
841            )
842            .await;
843        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
844        let data = resp.data.into_json().unwrap();
845        let cfg = &data["config"];
846        // Defaults from Config::default().
847        assert!(cfg["libraryFolders"].is_array());
848        assert!(cfg["targetFps"].as_i64().unwrap() > 0);
849        assert!(cfg["artSize"].as_i64().unwrap() > 0);
850    }
851
852    #[tokio::test]
853    async fn playlist_version_query() {
854        let (schema, _rx, _tmp) = test_schema();
855
856        let resp = schema.execute("{ playlistVersion }").await;
857        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
858        let data = resp.data.into_json().unwrap();
859        assert_eq!(data["playlistVersion"], 0);
860    }
861
862    #[tokio::test]
863    async fn subscription_types_in_schema() {
864        // Verify that subscriptions are registered by introspecting the schema.
865        let (schema, _rx, _tmp) = test_schema();
866
867        let resp = schema
868            .execute("{ __schema { subscriptionType { fields { name } } } }")
869            .await;
870        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
871        let data = resp.data.into_json().unwrap();
872        let fields = data["__schema"]["subscriptionType"]["fields"]
873            .as_array()
874            .unwrap();
875        let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
876        assert!(
877            names.contains(&"nowPlaying"),
878            "missing nowPlaying subscription"
879        );
880        assert!(
881            names.contains(&"queueUpdated"),
882            "missing queueUpdated subscription"
883        );
884        assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
885    }
886    // -- Fan-out and pagination --
887
888    #[tokio::test]
889    async fn nested_fan_out_opens_a_bounded_number_of_connections() {
890        let (schema, handle, _rx, tmp) = instrumented_schema();
891        seed_library(&tmp.path().join("test.db"), 10, 3, 4);
892
893        let before = handle.open_count();
894        let resp = schema
895            .execute(
896                "{ artists(first: 10) { edges { node { name albumCount trackCount \
897                   albums(first: 10) { edges { node { title trackCount totalDurationMs \
898                   tracks(first: 10) { edges { node { title isFavourite } } } } } } } } } }",
899            )
900            .await;
901        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
902
903        let data = resp.data.into_json().unwrap();
904        let artists = data["artists"]["edges"].as_array().unwrap();
905        assert_eq!(artists.len(), 10, "query did not actually fan out");
906        assert_eq!(artists[0]["node"]["albumCount"], 3);
907        assert_eq!(artists[0]["node"]["trackCount"], 12);
908
909        // Before pooling this was one full `Database::open` — DDL batch,
910        // migrations and a WAL checkpoint — per resolver field.
911        let opened = handle.open_count() - before;
912        assert!(opened <= 16, "opened {} connections", opened);
913    }
914
915    #[tokio::test]
916    async fn is_favourite_batches_into_one_query() {
917        let (schema, handle, _rx, tmp) = instrumented_schema();
918        seed_library(&tmp.path().join("test.db"), 1, 1, 100);
919
920        let before = handle.batch_count();
921        let resp = schema
922            .execute("{ tracks(first: 100) { edges { node { title isFavourite } } } }")
923            .await;
924        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
925        let data = resp.data.into_json().unwrap();
926        assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 100);
927
928        // The invariant is that the query count does not scale with the row
929        // count: without the dataloader this was one full `favourites` scan per
930        // track. The dataloader's gather window can close more than once under
931        // load, so assert the property rather than an exact batch count.
932        let batches = handle.batch_count() - before;
933        assert!(
934            batches < 10,
935            "{batches} batches for 100 tracks — expected a handful, not one per row"
936        );
937    }
938
939    #[tokio::test]
940    async fn tracks_without_first_returns_the_default_page() {
941        let (schema, _handle, _rx, tmp) = instrumented_schema();
942        seed_library(
943            &tmp.path().join("test.db"),
944            1,
945            1,
946            helpers::DEFAULT_PAGE + 20,
947        );
948
949        let resp = schema
950            .execute("{ tracks { edges { node { title } } pageInfo { hasNextPage } } }")
951            .await;
952        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
953        let data = resp.data.into_json().unwrap();
954        assert_eq!(
955            data["tracks"]["edges"].as_array().unwrap().len(),
956            helpers::DEFAULT_PAGE
957        );
958        assert_eq!(data["tracks"]["pageInfo"]["hasNextPage"], true);
959    }
960
961    #[tokio::test]
962    async fn first_is_clamped_to_the_maximum_page() {
963        let (schema, _handle, _rx, tmp) = instrumented_schema();
964        seed_library(&tmp.path().join("test.db"), 1, 1, 10);
965
966        let resp = schema
967            .execute("{ tracks(first: 100000) { edges { node { title } } } }")
968            .await;
969        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
970        let data = resp.data.into_json().unwrap();
971        assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 10);
972    }
973
974    #[tokio::test]
975    async fn negative_first_is_empty_not_a_panic() {
976        let (schema, _handle, _rx, tmp) = instrumented_schema();
977        seed_library(&tmp.path().join("test.db"), 2, 1, 3);
978
979        for query in [
980            "{ tracks(first: -1) { edges { node { title } } } }",
981            "{ artists(first: -1) { edges { node { name } } } }",
982            "{ albums(first: -1) { edges { node { title } } } }",
983        ] {
984            let resp = schema.execute(query).await;
985            assert!(resp.errors.is_empty(), "{}: {:?}", query, resp.errors);
986        }
987    }
988
989    #[tokio::test]
990    async fn sort_arguments_are_honoured() {
991        let (schema, _handle, _rx, tmp) = instrumented_schema();
992        seed_library(&tmp.path().join("test.db"), 3, 1, 2);
993
994        let resp = schema
995            .execute(
996                "{ tracks(first: 100, sortBy: TITLE, sortDir: DESC) { edges { node { title } } } }",
997            )
998            .await;
999        assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1000        let data = resp.data.into_json().unwrap();
1001        let titles: Vec<String> = data["tracks"]["edges"]
1002            .as_array()
1003            .unwrap()
1004            .iter()
1005            .map(|e| e["node"]["title"].as_str().unwrap().to_string())
1006            .collect();
1007        let mut sorted = titles.clone();
1008        sorted.sort();
1009        sorted.reverse();
1010        assert_eq!(titles, sorted, "sortBy/sortDir were ignored");
1011    }
1012
1013    /// A blocking resolver must not hold the runtime. On a single-threaded
1014    /// runtime, anything running inline would freeze every other task for the
1015    /// whole query — which is what stalled in-flight audio streams.
1016    #[test]
1017    fn a_slow_query_does_not_stall_other_tasks() {
1018        use std::sync::atomic::{AtomicUsize, Ordering};
1019
1020        let rt = tokio::runtime::Builder::new_current_thread()
1021            .enable_time()
1022            .build()
1023            .unwrap();
1024        let (schema, _handle, _rx, tmp) = instrumented_schema();
1025        seed_library(&tmp.path().join("test.db"), 20, 5, 8);
1026
1027        rt.block_on(async move {
1028            let ticks = Arc::new(AtomicUsize::new(0));
1029            let counter = ticks.clone();
1030            let ticker = tokio::spawn(async move {
1031                loop {
1032                    tokio::time::sleep(Duration::from_millis(1)).await;
1033                    counter.fetch_add(1, Ordering::Relaxed);
1034                }
1035            });
1036
1037            // fuzzySearch loads the library and builds a whole Nucleo matcher.
1038            let mut running = Vec::new();
1039            for _ in 0..4 {
1040                let schema = schema.clone();
1041                running.push(tokio::spawn(async move {
1042                    schema
1043                        .execute("{ fuzzySearch(query: \"track\") { id } }")
1044                        .await
1045                }));
1046            }
1047            for handle in running {
1048                let resp = handle.await.unwrap();
1049                assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1050            }
1051
1052            ticker.abort();
1053            assert!(
1054                ticks.load(Ordering::Relaxed) >= 2,
1055                "no other task ran while the queries were in flight"
1056            );
1057        });
1058    }
1059}