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
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")