sources: split each kind into a provider and its source
Assisted-by: Claude Opus 5.5 via Claude Code
change pmloszxprvvosnllxzpvpkprlrzzyuwz
commit a22a24c03381d27f47d88d6461d9f00487b228d0
author Alpha Chen <alpha@kejadlen.dev>
date
parent xxuuzvlt
diff --git a/AGENTS.md b/AGENTS.md
index 4b280b7..4e7e93d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,8 +26,8 @@ lib/rank_king/     — app code
   db.rb            — Sequel connection + migration runner
   models.rb        — Sequel models (User, List, Item, Axis, Result)
   inner_tube.rb    — YouTube client: InnerTube, signed with a pasted browser cookie
-  sources.rb       — Sources.fetch: a list's source_kind names a class in Sources
-  sources/         — one class per source kind
+  sources.rb       — Sources.providers: a list's source_kind names one of them
+  sources/         — one class per source, each with the Provider that makes it
   sync.rb          — reconciles a list's items with a fresh snapshot from a fetching source
   proxy_auth.rb    — reads the login from Remote-User
   seed.rb          — sample lists for the stub login (demo.ru)
@@ -51,7 +51,7 @@ fly.toml           — Fly config for the main demo and PR previews
 ## Domain model
 
 - **User** has many Lists; identified by the login on the `Remote-User` header
-- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names a class in `Sources`). Each source renders its own sync dialog on the list page and applies what it posts: `Text`'s edits items by id, and a kind that fetches calls `Sync.call`. A fetching kind keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
+- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names its provider in `Sources.providers`). Each source renders its own sync dialog on the list page and applies what it posts: `Text`'s edits items by id, and a source that fetches calls `Sync.call`. A fetching source keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
 - Sources: `Text` (typed in, edited by id) and `YouTube` (follows one of the owner's playlists, Watch Later included, with a youtube.com cookie pasted for each fetch and never kept; `youtube_sources` by `list_id`, `youtube_items` by `item_id`)
 - **Item** belongs to a List; removed items, and items missing from the latest sync, are archived (`archived_at`) and hidden everywhere
 - **Axis** belongs to a List, has many Results
@@ -87,9 +87,8 @@ fly.toml           — Fly config for the main demo and PR previews
 - `SESSION_SECRET` — session signing key (auto-generated if unset)
 
 Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`. Clients that
-talk to the outside world live on `Config`, which the app hands to every
-source; a source types the argument as an interface naming only what it
-reads (`Sources::_InnerTubeConfig`). The YouTube tests subclass the app
+talk to the outside world live on `Config`; `Sources.providers` hands
+each provider only the clients it needs. The YouTube tests subclass the app
 to override `config` with an `InnerTube` subclass whose `request`
 answers without the network.
 
@@ -127,12 +126,13 @@ rank-king never stores it.
   screen's own actions). List actions go in the list's ⋯ menu and list
   options in its settings form, so a new feature adds an entry there
   rather than a new button or screen.
-- A list's source is fixed when it is made. `Sources::KINDS` lists the
-  kinds the new-list form offers, and each kind's `title` names it there
-  and in settings. Its sync dialog opens from the list's ⋯ menu. Each
-  kind also renders its own fields on the new-list form (`fields`),
-  sets the new list up from them (`create`), and mounts any routes those
-  fields post to (`routes`).
+- A list's source is fixed when it is made. `Sources.providers` lists
+  the providers the new-list form offers, and each provider's `title`
+  names it there and in settings. Its sync dialog opens from the list's ⋯
+  menu. Each provider also renders its own fields on the new-list form
+  (`fields`), sets the new list up from them (`create`), mounts any
+  routes those fields post to (`routes`), and opens a list's source
+  (`source`).
 - Every request needs a `Remote-User` header or it gets a 401. That is safe only because a proxy (Tailscale identity) writes the header. `StubLogin` fills it in for `demo.ru` (dev server, Fly apps) and nowhere else: never in `config.ru`, and never in front of real data.
 - Lists are private to their owner (404 for anyone else)
 - Config is a frozen `Data.define` value object
diff --git a/lib/rank_king/sources.rb b/lib/rank_king/sources.rb
index b26d969..d95c7bd 100644
--- a/lib/rank_king/sources.rb
+++ b/lib/rank_king/sources.rb
@@ -9,7 +9,7 @@ module RankKing
     # @rbs skip
     Entry = Data.define(:key, :name)
 
-    # Raised by a kind's create when the new-list form did not post enough
+    # Raised by a provider's create when the new-list form did not post enough
     # to set the list up; the message says what is missing.
     class Invalid < StandardError; end
   end
@@ -20,12 +20,13 @@ require_relative "sources/youtube"
 
 module RankKing
   module Sources
-    # Every kind a list can be made from, in the order the new-list form
-    # offers them. A list keeps its kind for good.
-    KINDS = %w[Text YouTube].freeze #: Array[String]
-
-    # A list's source_kind names a class in this module.
-    #: (String kind) -> _SourceClass
-    def self.fetch(kind) = const_get(kind, false)
+    # Every provider a list can be made from, by the name a list's
+    # source_kind keeps, in the order the new-list form offers them. A list
+    # keeps its provider for good.
+    #: (Config config) -> Hash[String, _Provider]
+    def self.providers(config) = {
+      "Text" => Text::Provider.new,
+      "YouTube" => YouTube::Provider.new(config.inner_tube),
+    }
   end
 end
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index 1282e25..849eb39 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -8,27 +8,29 @@ module RankKing
     # over the list's items, which matches them by id, so there is nothing
     # to fetch and no table of its own.
     class Text
-      #: (List list, Config config) -> Text
-      def self.for(list, config) = new(list)
+      class Provider
+        #: () -> String
+        def title = "Typed list"
 
-      #: () -> String
-      def self.title = "Typed list"
+        #: (List list) -> Text
+        def source(list) = Text.new(list)
 
-      #: (csrf: Method) -> _Component
-      def self.fields(csrf:) = Views::Sources::TextFields.new
+        #: (csrf: Method) -> _Component
+        def fields(csrf:) = Views::Sources::TextFields.new
 
-      #: (Roda::RodaRequest r, Config config) -> void
-      def self.routes(r, config) = nil
+        #: (Roda::RodaRequest r) -> void
+        def routes(r) = nil
 
-      # Adds the items typed into the new-list form, of which there must
-      # be at least two.
-      #: (List list, Hash[String, untyped] params, Config config) -> void
-      def self.create(list, params, config)
-        names = params["items"].to_s.lines.map(&:strip).reject(&:empty?)
-        raise Invalid, "A list needs at least two items." if names.size < 2
+        # Adds the items typed into the new-list form, of which there must
+        # be at least two.
+        #: (List list, Hash[String, untyped] params) -> void
+        def create(list, params)
+          names = params["items"].to_s.lines.map(&:strip).reject(&:empty?)
+          raise Invalid, "A list needs at least two items." if names.size < 2
 
-        names.each do |name|
-          Item.create(list_id: list.id, name:)
+          names.each do |name|
+            Item.create(list_id: list.id, name:)
+          end
         end
       end
 
diff --git a/lib/rank_king/sources/youtube.rb b/lib/rank_king/sources/youtube.rb
index 85aab21..241611d 100644
--- a/lib/rank_king/sources/youtube.rb
+++ b/lib/rank_king/sources/youtube.rb
@@ -19,31 +19,49 @@ module RankKing
       # playlists.
       PLAYLISTS_PATH = "/youtube/playlists"
 
-      #: () -> String
-      def self.title = "YouTube playlist"
+      class Provider
+        #: (InnerTube inner_tube) -> void
+        def initialize(inner_tube)
+          @inner_tube = inner_tube
+        end
 
-      #: (List list, _InnerTubeConfig config) -> YouTube
-      def self.for(list, config) = new(list, config.inner_tube)
+        #: () -> String
+        def title = "YouTube playlist"
 
-      #: (csrf: Method) -> _Component
-      def self.fields(csrf:) = Views::Sources::YouTubeFields.new(csrf:)
-
-      # The new-list form's playlist picker, drawn from a pasted cookie. It
-      # is a POST so the cookie stays out of URLs and logs.
-      #: (Roda::RodaRequest r, _InnerTubeConfig config) -> void
-      def self.routes(r, config)
-        r.post PLAYLISTS_PATH.delete_prefix("/") do
-          playlists(r.params["cookie"], config).call
+        #: (List list) -> YouTube
+        def source(list) = YouTube.new(list, @inner_tube)
+
+        #: (csrf: Method) -> _Component
+        def fields(csrf:) = Views::Sources::YouTubeFields.new(csrf:)
+
+        # The new-list form's playlist picker, drawn from a pasted cookie. It
+        # is a POST so the cookie stays out of URLs and logs.
+        #: (Roda::RodaRequest r) -> void
+        def routes(r)
+          r.post PLAYLISTS_PATH.delete_prefix("/") do
+            playlists(r.params["cookie"]).call
+          end
         end
-      end
 
-      # The choice of playlists a pasted cookie opens. Whatever stops it
-      # is said in the choice's place.
-      #: (untyped text, _InnerTubeConfig config) -> _Component
-      def self.playlists(text, config)
-        Views::Sources::YouTubePlaylists.new(playlists: config.inner_tube.playlists(cookie(text)))
-      rescue InnerTube::Error => e
-        Views::Sources::YouTubePlaylists.new(playlists: nil, error: e.message)
+        # The choice of playlists a pasted cookie opens. Whatever stops it
+        # is said in the choice's place.
+        #: (untyped text) -> _Component
+        def playlists(text)
+          Views::Sources::YouTubePlaylists.new(playlists: @inner_tube.playlists(YouTube.cookie(text)))
+        rescue InnerTube::Error => e
+          Views::Sources::YouTubePlaylists.new(playlists: nil, error: e.message)
+        end
+
+        # Follows the playlist chosen on the new-list form and fetches it
+        # with the cookie pasted there.
+        #: (List list, Hash[String, untyped] params) -> void
+        def create(list, params)
+          playlist_id = params["playlist"].to_s[/\A[\w-]+\z/] or raise Invalid, "Choose a playlist to follow."
+          cookie = InnerTube.cookie(params["cookie"].to_s) or raise Invalid, "That cookie has no SAPISID to sign requests with."
+
+          DB[TABLE].insert(list_id: list.id, playlist_id:)
+          source(list).fetch(cookie)
+        end
       end
 
       #: (untyped text) -> String
@@ -51,17 +69,6 @@ module RankKing
         InnerTube.cookie(text.to_s) or raise InnerTube::Error, "That cookie has no SAPISID to sign requests with."
       end
 
-      # Follows the playlist chosen on the new-list form and fetches it
-      # with the cookie pasted there.
-      #: (List list, Hash[String, untyped] params, _InnerTubeConfig config) -> void
-      def self.create(list, params, config)
-        playlist_id = params["playlist"].to_s[/\A[\w-]+\z/] or raise Invalid, "Choose a playlist to follow."
-        cookie = InnerTube.cookie(params["cookie"].to_s) or raise Invalid, "That cookie has no SAPISID to sign requests with."
-
-        DB[TABLE].insert(list_id: list.id, playlist_id:)
-        new(list, config.inner_tube).fetch(cookie)
-      end
-
       #: (List list, InnerTube inner_tube) -> void
       def initialize(list, inner_tube)
         @list = list
diff --git a/lib/rank_king/sync.rb b/lib/rank_king/sync.rb
index b99ff10..a0b10f3 100644
--- a/lib/rank_king/sync.rb
+++ b/lib/rank_king/sync.rb
@@ -6,7 +6,7 @@ module RankKing
   # Reconciles a list's items with a fresh snapshot of entries from its
   # source. Items missing from the snapshot are archived rather than deleted
   # so their results survive, and come back with their history if they
-  # reappear. A kind that fetches its items calls this from its own sync.
+  # reappear. A source that fetches its items calls this from its own sync.
   module Sync
     # Signed in sig/rank_king/sync.rbs: a Data class.
     # @rbs skip
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 1101777..73c402e 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -40,7 +40,7 @@ module RankKing
         Views::Home.new(lists: lists).call
       end
 
-      Sources::KINDS.each { |kind| Sources.fetch(kind).routes(r, config) }
+      providers.each_value { |provider| provider.routes(r) }
 
       r.on "lists" do
         r.is do
@@ -49,7 +49,7 @@ module RankKing
             r.halt 422 if name.empty?
 
             source_kind = r.params["source"] || "Text"
-            r.halt 422 unless Sources::KINDS.include?(source_kind)
+            provider = providers[source_kind] or r.halt 422
 
             axis_names = names_param(r.params["axes"])
 
@@ -57,7 +57,7 @@ module RankKing
             # nothing is made.
             list = DB.transaction {
               l = List.create(name: name, user_id: user.id, source_kind: source_kind)
-              Sources.fetch(source_kind).create(l, r.params, config)
+              provider.create(l, r.params)
 
               axis_names.each do |n|
                 Axis.create(list_id: l.id, name: n)
@@ -70,11 +70,10 @@ module RankKing
         end
 
         r.get "new" do
-          kinds = Sources::KINDS.map { |kind|
-            source = Sources.fetch(kind)
-            [kind, source.title, source.fields(csrf: method(:csrf_token))] #: [String, String, Sources::_Component]
+          sources = providers.map { |name, provider|
+            [name, provider.title, provider.fields(csrf: method(:csrf_token))] #: [String, String, Sources::_Component]
           }
-          Views::ListViews::New.new(sources: kinds, csrf: method(:csrf_token)).call
+          Views::ListViews::New.new(sources:, csrf: method(:csrf_token)).call
         end
 
         r.on Integer do |list_id|
@@ -91,7 +90,7 @@ module RankKing
 
           r.is "settings" do
             r.get do
-              source = Sources.fetch(list.source_kind).title
+              source = provider(list).title
               Views::ListViews::Settings.new(list: list, source: source, csrf: method(:csrf_token)).call
             end
 
@@ -168,8 +167,14 @@ module RankKing
     #: () -> Config
     def config = CONFIG
 
+    #: () -> Hash[String, Sources::_Provider]
+    def providers = @providers ||= Sources.providers(config)
+
+    #: (List list) -> Sources::_Provider
+    def provider(list) = providers.fetch(list.source_kind)
+
     #: (List list) -> Sources::_Source
-    def source(list) = Sources.fetch(list.source_kind).for(list, config)
+    def source(list) = provider(list).source(list)
 
     # Names from a repeated field (`axes[]`), one per tag.
     #: (untyped) -> Array[String]
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index 66e12e3..eb6a6e9 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -1,6 +1,6 @@
 # Entry is a Data class, whose members the inline parser cannot read, and
-# the interfaces every source kind meets are named here so Sources.fetch
-# and List#source can be typed by them.
+# the interfaces every provider and source meets are named here so
+# Sources.providers can be typed by them.
 module RankKing
   module Sources
     class Entry
@@ -29,35 +29,31 @@ module RankKing
       def items_by_key: () -> Hash[String, Item]
 
       # Called by sync for each entry once its item is saved, to keep
-      # whatever the kind knows about the item in its own table.
+      # whatever the source knows about the item in its own table.
       def saved: (Item item, Entry entry) -> void
     end
 
-    # A source kind: a class in Sources, named by a list's source_kind.
-    interface _SourceClass
-      # The source for a list, configured from the kind's own table and
-      # the app's config.
-      def for: (List list, Config config) -> _Source
+    # What a list is made from, holding whatever of the app's config its
+    # sources need, named by a list's source_kind.
+    interface _Provider
+      # The source for a list made from this provider.
+      def source: (List list) -> _Source
 
-      # What the kind is called where a list's source is chosen or shown.
+      # What the provider is called where a list's source is chosen or
+      # shown.
       def title: () -> String
 
-      # The kind's own fields on the new-list form, which may post to
+      # The provider's own fields on the new-list form, which may post to
       # routes of their own as they are filled in.
       def fields: (csrf: Method) -> _Component
 
       # Sets a new list up from what those fields posted, raising Invalid,
       # and the list is not made, when they are not enough to go on.
-      def create: (List list, Hash[String, untyped] params, Config config) -> void
+      def create: (List list, Hash[String, untyped] params) -> void
 
-      # Mounts the routes the kind's own fields and dialogs post to, ahead
-      # of the app's.
-      def routes: (Roda::RodaRequest r, Config config) -> void
-    end
-
-    # All a YouTube source reads off the app's config.
-    interface _InnerTubeConfig
-      def inner_tube: () -> InnerTube
+      # Mounts the routes the provider's own fields and dialogs post to,
+      # ahead of the app's.
+      def routes: (Roda::RodaRequest r) -> void
     end
 
     # A view component, as a source's form is.
diff --git a/test/test_sync.rb b/test/test_sync.rb
index 20acff9..ad0f49b 100644
--- a/test/test_sync.rb
+++ b/test/test_sync.rb
@@ -10,7 +10,7 @@ class TestSync < Minitest::Test
   include RankKing
 
   # A synced source that keeps each item's key in memory, standing in for a
-  # kind that keeps its keys in its own table keyed by item_id.
+  # source that keeps its keys in its own table keyed by item_id.
   class FakeSource
     attr_reader :calls