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
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,