Type the sources code for Steep and drop the serialization plugin
Main now runs Steep, so the sources, sync and new model columns get
signatures. The JSON columns are parsed by hand: Sequel's serialization
plugin passes json 3.1 a create_additions option it no longer accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
change
commit 664e4ccb2e6987bfc415fd59f22923537a6e82cb
author Claude <noreply@anthropic.com>
date
parent 863f8611
diff --git a/Steepfile b/Steepfile
index ae41aec..5f3fdfb 100644
--- a/Steepfile
+++ b/Steepfile
@@ -11,4 +11,5 @@ target :lib do
   signature "sig"
 
   library "securerandom"
+  library "json"
 end
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 14fc9bb..8cad246 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+require "json"
+
 require_relative "db"
 require_relative "sources"
 
@@ -13,14 +15,22 @@ module RankKing
     one_to_many :items, class: "RankKing::Item", conditions: { archived_at: nil }
     one_to_many :axes, class: "RankKing::Axis"
 
-    plugin :serialization, :json, :source_config
+    def source_config = JSON.parse(super)
+    def source_config=(value)
+      super(JSON.generate(value))
+    end
 
     def source = Sources.fetch(source_kind).new(source_config)
   end
 
   # @rbs skip
   class Item < Sequel::Model(DB[:items])
-    plugin :serialization, :json, :metadata
+    # JSON by hand rather than Sequel's serialization plugin, which passes
+    # json 3.1 an option it no longer takes.
+    def metadata = JSON.parse(super)
+    def metadata=(value)
+      super(JSON.generate(value))
+    end
 
     many_to_one :list, class: "RankKing::List"
   end
diff --git a/lib/rank_king/sources.rb b/lib/rank_king/sources.rb
index 9e65d66..f728529 100644
--- a/lib/rank_king/sources.rb
+++ b/lib/rank_king/sources.rb
@@ -4,11 +4,9 @@ module RankKing
   module Sources
     # What every source produces: one entry per item currently in the source.
     # external_id is how sync recognizes the same item across snapshots.
-    Entry = Data.define(:external_id, :name, :url, :metadata) do
-      def initialize(external_id:, name:, url: nil, metadata: {})
-        super
-      end
-    end
+    # Signed in sig/rank_king/sources.rbs: a Data class.
+    # @rbs skip
+    Entry = Data.define(:external_id, :name, :url, :metadata)
   end
 end
 
@@ -18,8 +16,9 @@ module RankKing
   module Sources
     ALL = {
       Text.kind => Text,
-    }.freeze
+    }.freeze #: Hash[String, _SourceClass]
 
+    #: (String kind) -> _SourceClass
     def self.fetch(kind) = ALL.fetch(kind)
   end
 end
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index 875136a..8f274a6 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -4,16 +4,23 @@ module RankKing
   module Sources
     # A push source: the snapshot is pasted text, one item per line.
     class Text
+      # @rbs @config: Hash[String, untyped]
+
+      #: () -> String
       def self.kind = "text"
+
+      #: () -> bool
       def self.push? = true
 
+      #: (?Hash[String, untyped] config) -> void
       def initialize(config = {})
         @config = config
       end
 
+      #: (untyped snapshot) -> Array[Entry]
       def entries(snapshot)
         snapshot.to_s.lines.map(&:strip).reject(&:empty?).uniq.map {|line|
-          Entry.new(external_id: line, name: line)
+          Entry.new(external_id: line, name: line, url: nil, metadata: {})
         }
       end
     end
diff --git a/lib/rank_king/sync.rb b/lib/rank_king/sync.rb
index 9aa76f7..344fb8c 100644
--- a/lib/rank_king/sync.rb
+++ b/lib/rank_king/sync.rb
@@ -8,8 +8,11 @@ module RankKing
   # so their results survive, and come back with their history if they
   # reappear.
   module Sync
+    # Signed in sig/rank_king/sync.rbs: a Data class.
+    # @rbs skip
     Summary = Data.define(:added, :restored, :archived)
 
+    #: (List list, Array[Sources::Entry] entries, ?now: Time) -> Summary
     def self.call(list, entries, now: Time.now)
       DB.transaction do
         existing = Item.where(list_id: list.id).exclude(external_id: nil).to_hash(:external_id)
@@ -19,9 +22,9 @@ module RankKing
           attrs = { name: entry.name, url: entry.url, metadata: entry.metadata, archived_at: nil }
           if (item = existing.delete(entry.external_id))
             restored += 1 if item.archived_at
-            item.update(attrs)
+            item.update(**attrs)
           else
-            Item.create(attrs.merge(list_id: list.id, external_id: entry.external_id))
+            Item.create(**attrs, list_id: list.id, external_id: entry.external_id)
             added += 1
           end
         end
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 54ae822..fcb1bec 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -25,11 +25,16 @@ module Sequel
   class Dataset[out T]
     def where: (*untyped conditions) -> Dataset[T]
 
+    def exclude: (*untyped conditions) -> Dataset[T]
+
+    # Rows keyed by one column's value.
+    def to_hash: (Symbol key) -> Hash[untyped, T]
+
     def first: () -> T?
   end
 
   class Model
-    def self.plugin: (Symbol name, **untyped options) -> void
+    def self.plugin: (Symbol name, *untyped args, **untyped options) -> void
 
     def self.one_to_many: (Symbol name, **untyped options) -> void
 
@@ -39,10 +44,15 @@ module Sequel
 
     def self.all: () -> Array[instance]
 
+    def self.where: (*untyped conditions) -> Dataset[instance]
+
     # The row with this primary key, or nil.
     def self.[]: (untyped id) -> instance?
 
     def id: () -> Integer
+
+    # Sets the columns given and saves the row.
+    def update: (**untyped values) -> self
   end
 
   # Applies the migrations in a directory that the database has not run
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
index 4c373f5..68b688f 100644
--- a/sig/rank_king/models.rbs
+++ b/sig/rank_king/models.rbs
@@ -8,12 +8,22 @@ module RankKing
     def axes: () -> Array[Axis]
     def items_dataset: () -> Sequel::Dataset[Item]
     def axes_dataset: () -> Sequel::Dataset[Axis]
+    def source_kind: () -> String
+    def source_config: () -> Hash[String, untyped]
+    def source_config=: (Hash[String, untyped] value) -> void
+    def synced_at: () -> Time?
+    def source: () -> Sources::_Source
   end
 
   class Item < Sequel::Model
     def name: () -> String
     def list_id: () -> Integer
     def list: () -> List
+    def external_id: () -> String?
+    def url: () -> String?
+    def metadata: () -> Hash[String, untyped]
+    def metadata=: (Hash[String, untyped] value) -> void
+    def archived_at: () -> Time?
   end
 
   class Axis < Sequel::Model
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
new file mode 100644
index 0000000..f389c79
--- /dev/null
+++ b/sig/rank_king/sources.rbs
@@ -0,0 +1,28 @@
+# Entry is a Data class, whose members the inline parser cannot read, and
+# the interfaces every source kind meets are named here so Sources::ALL
+# and List#source can be typed by them.
+module RankKing
+  module Sources
+    class Entry
+      attr_reader external_id: String
+      attr_reader name: String
+      attr_reader url: String?
+      attr_reader metadata: Hash[String, untyped]
+
+      def self.new: (external_id: String, name: String, url: String?, metadata: Hash[String, untyped]) -> instance
+    end
+
+    # A source instance: turns a snapshot (the pasted text for a push
+    # source, ignored by a pull source) into entries.
+    interface _Source
+      def entries: (untyped snapshot) -> Array[Entry]
+    end
+
+    # A source kind, the class registered in Sources::ALL.
+    interface _SourceClass
+      def kind: () -> String
+      def push?: () -> bool
+      def new: (Hash[String, untyped] config) -> _Source
+    end
+  end
+end
diff --git a/sig/rank_king/sync.rbs b/sig/rank_king/sync.rbs
new file mode 100644
index 0000000..798b1e6
--- /dev/null
+++ b/sig/rank_king/sync.rbs
@@ -0,0 +1,12 @@
+# Summary is a Data class, whose members the inline parser cannot read.
+module RankKing
+  module Sync
+    class Summary
+      attr_reader added: Integer
+      attr_reader restored: Integer
+      attr_reader archived: Integer
+
+      def self.new: (added: Integer, restored: Integer, archived: Integer) -> instance
+    end
+  end
+end
diff --git a/test/test_sync.rb b/test/test_sync.rb
index 571a8e6..2801442 100644
--- a/test/test_sync.rb
+++ b/test/test_sync.rb
@@ -47,7 +47,7 @@ class TestSync < Minitest::Test
   end
 
   def test_refreshes_name_url_and_metadata
-    Sync.call(@list, [Sources::Entry.new(external_id: "v1", name: "Old")])
+    Sync.call(@list, [Sources::Entry.new(external_id: "v1", name: "Old", url: nil, metadata: {})])
     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")