Give each list exactly one source
A list's source now lives on the list (source_kind, source_config,
synced_at) instead of a separate sources table, and sync is POST
/lists/:id/sync. Items are unique on (list_id, external_id).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
change
commit 2f91a883059ec1377c3c7c75571663434334f363
author Claude <noreply@anthropic.com>
date
parent 6993e6b8
diff --git a/AGENTS.md b/AGENTS.md
index ecac0cf..2fcd00e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,10 +21,10 @@ db/migrate/        — Sequel migrations
 lib/rank_king/     — app code
   config.rb        — Config data class, reads from ENV
   db.rb            — Sequel connection + migration runner
-  models.rb        — Sequel models (List, Item, Source, Axis, Result)
+  models.rb        — Sequel models (List, Item, Axis, Result)
   sources.rb       — hardcoded registry of source kinds (Sources::ALL)
   sources/         — one class per source kind
-  sync.rb          — reconciles a source's items with a fresh snapshot
+  sync.rb          — reconciles a list's items with a fresh snapshot from its source
   web.rb           — Roda routes
   views/           — Phlex view components
 lib/open_skill/    — OpenSkill rating library (pure Ruby)
@@ -35,9 +35,8 @@ public/            — static assets
 
 ## Domain model
 
-- **List** has many Items, Sources, and Axes
-- **Source** belongs to a List and produces its Items; `kind` picks a class from `Sources::ALL`
-- **Item** belongs to a List and a Source; items missing from the latest sync are archived (`archived_at`) and hidden everywhere
+- **List** has many Items and Axes, and one source (`source_kind` picks a class from `Sources::ALL`, `source_config` configures it) that produces its Items
+- **Item** belongs to a List; items missing from the latest sync are archived (`archived_at`) and hidden everywhere
 - **Axis** belongs to a List, has many Results
 - **Result** belongs to an Axis, a winner (Item), and a loser (Item)
 
diff --git a/db/migrate/002_add_list_sources.rb b/db/migrate/002_add_list_sources.rb
new file mode 100644
index 0000000..b2e305b
--- /dev/null
+++ b/db/migrate/002_add_list_sources.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+  up do
+    # Every list gets its items from exactly one source. Lists before this
+    # were made from the textarea, so they default to the text source.
+    alter_table(:lists) do
+      add_column :source_kind, String, null: false, default: "text"
+      add_column :source_config, String, null: false, default: "{}"
+      add_column :synced_at, DateTime
+    end
+
+    alter_table(:items) do
+      add_column :external_id, String
+      add_column :url, String
+      add_column :metadata, String, null: false, default: "{}"
+      add_column :archived_at, DateTime
+      add_index [:list_id, :external_id], unique: true
+    end
+
+    # A text item's external id is its name. Duplicate names keep a nil
+    # external_id and are left alone by sync.
+    first_ids = from(:items).group(:list_id, :name).select { min(:id) }
+    from(:items).where(id: first_ids).update(external_id: :name)
+  end
+
+  down do
+    alter_table(:items) do
+      drop_index [:list_id, :external_id]
+      drop_column :external_id
+      drop_column :url
+      drop_column :metadata
+      drop_column :archived_at
+    end
+
+    alter_table(:lists) do
+      drop_column :source_kind
+      drop_column :source_config
+      drop_column :synced_at
+    end
+  end
+end
diff --git a/db/migrate/002_create_sources.rb b/db/migrate/002_create_sources.rb
deleted file mode 100644
index f1a905a..0000000
--- a/db/migrate/002_create_sources.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-Sequel.migration do
-  up do
-    create_table(:sources) do
-      primary_key :id
-      foreign_key :list_id, :lists, null: false
-      String :kind, null: false
-      String :config, null: false, default: "{}"
-      DateTime :last_synced_at
-      DateTime :created_at
-      DateTime :updated_at
-    end
-
-    alter_table(:items) do
-      add_foreign_key :source_id, :sources
-      add_column :external_id, String
-      add_column :url, String
-      add_column :metadata, String, null: false, default: "{}"
-      add_column :archived_at, DateTime
-      add_index [:source_id, :external_id], unique: true
-    end
-
-    # Existing lists were created from the textarea, so give each one a text
-    # source. Duplicate names keep a nil external_id and are left alone by sync.
-    from(:lists).each do |list|
-      source_id = from(:sources).insert(
-        list_id: list[:id], kind: "text", config: "{}",
-        created_at: Time.now, updated_at: Time.now,
-      )
-      from(:items).where(list_id: list[:id]).update(source_id: source_id)
-
-      first_ids = from(:items).where(list_id: list[:id]).group(:name).select { min(:id) }
-      from(:items).where(id: first_ids).update(external_id: :name)
-    end
-  end
-
-  down do
-    alter_table(:items) do
-      drop_index [:source_id, :external_id]
-      drop_foreign_key :source_id
-      drop_column :external_id
-      drop_column :url
-      drop_column :metadata
-      drop_column :archived_at
-    end
-
-    drop_table(:sources)
-  end
-end
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 9fa838f..b5a4ae4 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -10,23 +10,16 @@ module RankKing
     # Archived items are hidden everywhere for now.
     one_to_many :items, class: "RankKing::Item", conditions: { archived_at: nil }
     one_to_many :axes, class: "RankKing::Axis"
-    one_to_many :sources, class: "RankKing::Source"
-  end
 
-  class Item < Sequel::Model(DB[:items])
-    plugin :serialization, :json, :metadata
+    plugin :serialization, :json, :source_config
 
-    many_to_one :list, class: "RankKing::List"
-    many_to_one :source, class: "RankKing::Source"
+    def source = Sources.fetch(source_kind).new(source_config)
   end
 
-  class Source < Sequel::Model(DB[:sources])
-    plugin :serialization, :json, :config
+  class Item < Sequel::Model(DB[:items])
+    plugin :serialization, :json, :metadata
 
     many_to_one :list, class: "RankKing::List"
-    one_to_many :items, class: "RankKing::Item"
-
-    def plugin = Sources.fetch(kind).new(config)
   end
 
   class Axis < Sequel::Model(DB[:axes])
diff --git a/lib/rank_king/sync.rb b/lib/rank_king/sync.rb
index 02f07ce..9aa76f7 100644
--- a/lib/rank_king/sync.rb
+++ b/lib/rank_king/sync.rb
@@ -3,15 +3,16 @@
 require_relative "models"
 
 module RankKing
-  # Reconciles a source's items with a fresh snapshot of entries. Items missing
-  # from the snapshot are archived rather than deleted so their results survive,
-  # and come back with their history if they reappear.
+  # 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.
   module Sync
     Summary = Data.define(:added, :restored, :archived)
 
-    def self.call(source, entries, now: Time.now)
+    def self.call(list, entries, now: Time.now)
       DB.transaction do
-        existing = source.items_dataset.exclude(external_id: nil).to_hash(:external_id)
+        existing = Item.where(list_id: list.id).exclude(external_id: nil).to_hash(:external_id)
         added = restored = 0
 
         entries.each do |entry|
@@ -20,7 +21,7 @@ module RankKing
             restored += 1 if item.archived_at
             item.update(attrs)
           else
-            Item.create(attrs.merge(list_id: source.list_id, source_id: source.id, external_id: entry.external_id))
+            Item.create(attrs.merge(list_id: list.id, external_id: entry.external_id))
             added += 1
           end
         end
@@ -28,7 +29,7 @@ module RankKing
         gone = existing.values.select {|item| item.archived_at.nil? }
         gone.each {|item| item.update(archived_at: now) }
 
-        source.update(last_synced_at: now)
+        list.update(synced_at: now)
         Summary.new(added:, restored:, archived: gone.size)
       end
     end
diff --git a/lib/rank_king/views/list/show.rb b/lib/rank_king/views/list/show.rb
index 050855c..cb04a81 100644
--- a/lib/rank_king/views/list/show.rb
+++ b/lib/rank_king/views/list/show.rb
@@ -18,7 +18,7 @@ module RankKing
             div(class: "list-show") do
               h1 { @list.name }
               render_items
-              render_sources
+              render_source
               render_axes
               render_matchup
               render_ratings
@@ -39,30 +39,26 @@ module RankKing
           end
         end
 
-        def render_sources
-          section(class: "section") do
-            h2 { "Sources" }
-            @list.sources.each do |source|
-              render_source(source)
-            end
-          end
-        end
+        def render_source
+          source = @list.source
+          action = "/lists/#{@list.id}/sync"
 
-        def render_source(source)
-          action = "/lists/#{@list.id}/sources/#{source.id}/sync"
-          form(method: "post", action:, class: "form") do
-            input(type: "hidden", name: "_csrf", value: @csrf.call(action))
-
-            if source.plugin.class.push?
-              div(class: "field") do
-                label(for: "snapshot-#{source.id}") { "#{source.kind.capitalize} (one per line)" }
-                textarea(name: "snapshot", id: "snapshot-#{source.id}", rows: 6) do
-                  source.items_dataset.where(archived_at: nil).order(:id).map(:name).join("\n")
+          section(class: "section") do
+            h2 { "Source" }
+            form(method: "post", action:, class: "form") do
+              input(type: "hidden", name: "_csrf", value: @csrf.call(action))
+
+              if source.class.push?
+                div(class: "field") do
+                  label(for: "snapshot") { "Items (one per line)" }
+                  textarea(name: "snapshot", id: "snapshot", rows: 6) do
+                    @list.items_dataset.order(:id).map(:name).join("\n")
+                  end
                 end
               end
-            end
 
-            button(type: "submit", class: "btn-primary") { "Sync" }
+              button(type: "submit", class: "btn-primary") { "Sync" }
+            end
           end
         end
 
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 17fd33b..8c0ae1d 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -35,9 +35,8 @@ module RankKing
             axis_names = axes_text.lines.map(&:strip).reject(&:empty?)
 
             list = DB.transaction do
-              l = List.create(name: name)
-              source = Source.create(list_id: l.id, kind: Sources::Text.kind)
-              Sync.call(source, entries)
+              l = List.create(name: name, source_kind: Sources::Text.kind)
+              Sync.call(l, entries)
               axis_names.each { |n| Axis.create(list_id: l.id, name: n) }
               l
             end
@@ -56,12 +55,9 @@ module RankKing
             end
           end
 
-          r.on "sources", Integer, "sync" do |source_id|
+          r.on "sync" do
             r.post do
-              source = @list.sources_dataset.where(id: source_id).first
-              r.halt 404 unless source
-
-              Sync.call(source, source.plugin.entries(r.params["snapshot"]))
+              Sync.call(@list, @list.source.entries(r.params["snapshot"]))
 
               r.redirect "/lists/#{@list.id}"
             end
diff --git a/test/test_sync.rb b/test/test_sync.rb
index 59c478d..571a8e6 100644
--- a/test/test_sync.rb
+++ b/test/test_sync.rb
@@ -13,11 +13,9 @@ class TestSync < Minitest::Test
     DB[:results].delete
     DB[:axes].delete
     DB[:items].delete
-    DB[:sources].delete
     DB[:lists].delete
 
     @list = List.create(name: "Videos")
-    @source = Source.create(list_id: @list.id, kind: "text")
   end
 
   def test_adds_new_entries
@@ -25,7 +23,7 @@ class TestSync < Minitest::Test
 
     assert_equal Sync::Summary.new(added: 2, restored: 0, archived: 0), summary
     assert_equal %w[A B], names
-    refute_nil @source.reload.last_synced_at
+    refute_nil @list.reload.synced_at
   end
 
   def test_archives_missing_entries_and_keeps_their_rows
@@ -49,8 +47,8 @@ class TestSync < Minitest::Test
   end
 
   def test_refreshes_name_url_and_metadata
-    Sync.call(@source, [Sources::Entry.new(external_id: "v1", name: "Old")])
-    Sync.call(@source, [Sources::Entry.new(external_id: "v1", name: "New", url: "https://example.com/v1", metadata: { "thumb" => "t.jpg" })])
+    Sync.call(@list, [Sources::Entry.new(external_id: "v1", name: "Old")])
+    Sync.call(@list, [Sources::Entry.new(external_id: "v1", name: "New", url: "https://example.com/v1", metadata: { "thumb" => "t.jpg" })])
 
     item = Item.first(external_id: "v1")
     assert_equal "New", item.name
@@ -65,7 +63,7 @@ class TestSync < Minitest::Test
 
   private
 
-  def sync(*lines) = Sync.call(@source, Sources::Text.new.entries(lines.join("\n")))
+  def sync(*lines) = Sync.call(@list, Sources::Text.new.entries(lines.join("\n")))
 
   def names = @list.items_dataset.order(:id).map(:name)
 end
diff --git a/test/test_web.rb b/test/test_web.rb
index c08c568..d9eb696 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -16,7 +16,6 @@ class TestWeb < Minitest::Test
     RankKing::DB[:results].delete
     RankKing::DB[:axes].delete
     RankKing::DB[:items].delete
-    RankKing::DB[:sources].delete
     RankKing::DB[:lists].delete
   end
 
@@ -126,16 +125,15 @@ class TestWeb < Minitest::Test
     create_list(name: "Foods", items: "Pizza\nSushi")
     list = RankKing::List.first
 
-    assert_equal ["text"], list.sources.map(&:kind)
-    assert_equal %w[Pizza Sushi], list.sources.first.items.map(&:external_id)
+    assert_equal "text", list.source_kind
+    assert_equal %w[Pizza Sushi], list.items.map(&:external_id)
   end
 
   def test_sync_adds_and_hides_items
     create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
     list = RankKing::List.first
-    source = list.sources.first
 
-    csrf_post "/lists/#{list.id}/sources/#{source.id}/sync", { snapshot: "Pizza\nTacos" }
+    csrf_post "/lists/#{list.id}/sync", { snapshot: "Pizza\nTacos" }
     assert last_response.redirect?
 
     assert_equal %w[Pizza Tacos], list.items_dataset.order(:id).map(:name)
@@ -149,7 +147,7 @@ class TestWeb < Minitest::Test
     create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: "Taste")
     list = RankKing::List.first
     sushi = list.items.find { |i| i.name == "Sushi" }
-    RankKing::Sync.call(list.sources.first, RankKing::Sources::Text.new.entries("Pizza\nTacos"))
+    RankKing::Sync.call(list, RankKing::Sources::Text.new.entries("Pizza\nTacos"))
 
     csrf_post "/lists/#{list.id}/results", {
       axis_id: list.axes.first.id,