user follows/followed by scans for bluesky

This commit is contained in:
Dylan Knutson
2025-08-14 17:03:36 +00:00
parent e1933104b3
commit 1d248c1f23
37 changed files with 6145 additions and 38 deletions

View File

@@ -93,11 +93,18 @@ module GoodJobHelper
sig { params(job: GoodJob::Job).returns(T::Array[JobArg]) }
def arguments_for_job(job)
deserialized =
T.cast(
ActiveJob::Arguments.deserialize(job.serialized_params).to_h,
T::Hash[String, T.untyped],
begin
deserialized =
T.cast(
ActiveJob::Arguments.deserialize(job.serialized_params).to_h,
T::Hash[String, T.untyped],
)
rescue ActiveJob::DeserializationError => e
Rails.logger.error(
"error deserializing job arguments: #{e.class.name} - #{e.message}",
)
return [JobArg.new(key: :error, value: e.message, inferred: true)]
end
args_hash =
T.cast(deserialized["arguments"].first, T::Hash[Symbol, T.untyped])
args =

View File

@@ -106,11 +106,12 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
num_filtered_posts = 0
num_created_posts = 0
num_pages = 0
posts_scan = Domain::UserJobEvent::PostsScan.create!(user:)
loop do
url = cursor ? "#{posts_url}&cursor=#{cursor}" : posts_url
response = http_client.get(url)
posts_scan.update!(log_entry: response.log_entry) if num_pages == 0
num_pages += 1
if response.status_code != 200
@@ -176,6 +177,10 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
end
user.scanned_posts_at = Time.current
posts_scan.update!(
total_posts_seen: num_processed_posts,
new_posts_seen: num_created_posts,
)
logger.info(
format_tags(
"scanned posts",

View File

@@ -0,0 +1,264 @@
# typed: strict
# frozen_string_literal: true
class Domain::Bluesky::Job::ScanUserFollowsJob < Domain::Bluesky::Job::Base
self.default_priority = -10
sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
def perform(args)
user = user_from_args!
last_follows_scan = user.follows_scans.last
if (ca = last_follows_scan&.created_at) && (ca > 1.month.ago) &&
!force_scan?
logger.info(
format_tags(
"skipping user #{user.did} follows scan",
make_tags(
ago: time_ago_in_words(ca),
last_scan_id: last_follows_scan.id,
),
),
)
else
perform_scan_type(
user,
"follows",
bsky_method: "app.bsky.graph.getFollows",
bsky_field: "follows",
edge_name: :user_user_follows_from,
user_attr: :from_id,
other_attr: :to_id,
)
end
last_followed_by_scan = user.followed_by_scans.last
if (ca = last_followed_by_scan&.created_at) && (ca > 1.month.ago) &&
!force_scan?
logger.info(
format_tags(
"skipping user #{user.did} followed by scan",
make_tags(
ago: time_ago_in_words(ca),
last_scan_id: last_followed_by_scan.id,
),
),
)
else
perform_scan_type(
user,
"followed_by",
bsky_method: "app.bsky.graph.getFollowers",
bsky_field: "followers",
edge_name: :user_user_follows_to,
user_attr: :to_id,
other_attr: :from_id,
)
end
end
private
sig do
params(
user: Domain::User::BlueskyUser,
kind: String,
bsky_method: String,
bsky_field: String,
edge_name: Symbol,
user_attr: Symbol,
other_attr: Symbol,
).void
end
def perform_scan_type(
user,
kind,
bsky_method:,
bsky_field:,
edge_name:,
user_attr:,
other_attr:
)
scan = Domain::UserJobEvent::FollowScan.create!(user:, kind:)
cursor = T.let(nil, T.nilable(String))
page = 0
subjects_data = T.let([], T::Array[Bluesky::Graph::Subject])
loop do
# get followers
xrpc_url =
"https://public.api.bsky.app/xrpc/#{bsky_method}?actor=#{user.did!}&limit=100"
xrpc_url = "#{xrpc_url}&cursor=#{cursor}" if cursor
response = http_client.get(xrpc_url)
scan.update!(log_entry: response.log_entry) if page == 0
page += 1
if response.status_code != 200
fatal_error(
format_tags(
"failed to get user #{kind}",
make_tags(status_code: response.status_code),
),
)
end
data = JSON.parse(response.body)
if data["error"]
fatal_error(
format_tags(
"failed to get user #{kind}",
make_tags(error: data["error"]),
),
)
end
subjects_data.concat(
data[bsky_field].map do |subject_data|
Bluesky::Graph::Subject.from_json(subject_data)
end,
)
cursor = data["cursor"]
break if cursor.nil?
end
handle_subjects_data(
user,
subjects_data,
scan,
edge_name:,
user_attr:,
other_attr:,
)
scan.update!(state: "completed", completed_at: Time.current)
logger.info(
format_tags(
"completed user #{kind} scan",
make_tags(num_subjects: subjects_data.size),
),
)
rescue => e
scan.update!(state: "error", completed_at: Time.current) if scan
raise e
end
sig do
params(
user: Domain::User::BlueskyUser,
subjects: T::Array[Bluesky::Graph::Subject],
scan: Domain::UserJobEvent::FollowScan,
edge_name: Symbol,
user_attr: Symbol,
other_attr: Symbol,
).void
end
def handle_subjects_data(
user,
subjects,
scan,
edge_name:,
user_attr:,
other_attr:
)
subjects_by_did =
T.cast(subjects.index_by(&:did), T::Hash[String, Bluesky::Graph::Subject])
users_by_did =
T.cast(
Domain::User::BlueskyUser.where(did: subjects_by_did.keys).index_by(
&:did
),
T::Hash[String, Domain::User::BlueskyUser],
)
missing_user_dids = subjects_by_did.keys - users_by_did.keys
missing_user_dids.each do |did|
subject = subjects_by_did[did] || next
users_by_did[did] = create_user_from_subject(subject)
end
users_by_id = users_by_did.values.map { |u| [T.must(u.id), u] }.to_h
existing_subject_ids =
T.cast(user.send(edge_name).pluck(other_attr), T::Array[Integer])
new_user_ids = users_by_did.values.map(&:id).compact - existing_subject_ids
removed_user_ids =
existing_subject_ids - users_by_did.values.map(&:id).compact
follow_upsert_attrs = []
unfollow_upsert_attrs = []
referenced_user_ids = Set.new([user.id])
new_user_ids.each do |new_user_id|
new_user_did = users_by_id[new_user_id]&.did
followed_at = new_user_did && subjects_by_did[new_user_did]&.created_at
referenced_user_ids.add(new_user_id)
follow_upsert_attrs << {
user_attr => user.id,
other_attr => new_user_id,
:followed_at => followed_at,
:removed_at => nil,
}
end
removed_at = Time.current
removed_user_ids.each do |removed_user_id|
referenced_user_ids.add(removed_user_id)
unfollow_upsert_attrs << {
user_attr => user.id,
other_attr => removed_user_id,
:removed_at => removed_at,
}
end
Domain::User.transaction do
follow_upsert_attrs.each_slice(5000) do |slice|
Domain::UserUserFollow.upsert_all(slice, unique_by: %i[from_id to_id])
end
unfollow_upsert_attrs.each_slice(5000) do |slice|
Domain::UserUserFollow.upsert_all(slice, unique_by: %i[from_id to_id])
end
end
# reset counter caches
Domain::User.transaction do
referenced_user_ids.each do |user_id|
Domain::User.reset_counters(
user_id,
:user_user_follows_from,
:user_user_follows_to,
)
end
end
update_attrs = {
num_created_users: missing_user_dids.size,
num_existing_assocs: existing_subject_ids.size,
num_new_assocs: new_user_ids.size,
num_removed_assocs: removed_user_ids.size,
num_total_assocs: subjects.size,
}
logger.info(
format_tags("updated user #{edge_name}", make_tags(update_attrs)),
)
scan.update_json_attributes!(update_attrs)
end
sig do
params(subject: Bluesky::Graph::Subject).returns(Domain::User::BlueskyUser)
end
def create_user_from_subject(subject)
user =
Domain::User::BlueskyUser.create!(
did: subject.did,
handle: subject.handle,
display_name: subject.display_name,
description: subject.description,
)
avatar = user.create_avatar(url_str: subject.avatar)
defer_job(Domain::Bluesky::Job::ScanUserJob, { user: }, { priority: 0 })
defer_job(Domain::UserAvatarJob, { avatar: }, { priority: -1 })
user
end
end

View File

@@ -8,7 +8,6 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
logger.push_tags(make_arg_tag(user))
logger.info(format_tags("starting profile scan"))
return if buggy_user?(user)
if !user.profile_scan.due? && !force_scan?
logger.info(
format_tags(
@@ -16,12 +15,10 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
make_tags(scanned_at: user.profile_scan.ago_in_words),
),
)
enqueue_scan_posts_job_if_due(user)
return
end
scan_user_profile(user)
enqueue_scan_posts_job_if_due(user)
logger.info(format_tags("completed profile scan"))
ensure
user.save! if user
@@ -55,6 +52,7 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
sig { params(user: Domain::User::BlueskyUser).void }
def scan_user_profile(user)
logger.info(format_tags("scanning user profile"))
profile_scan = Domain::UserJobEvent::ProfileScan.create!(user:)
# Use AT Protocol API to get user profile
profile_url =
@@ -62,6 +60,7 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
response = http_client.get(profile_url)
user.last_scan_log_entry = response.log_entry
profile_scan.update!(log_entry: response.log_entry)
if response.status_code != 200
fatal_error(

View File

@@ -2,6 +2,7 @@
class Domain::StaticFileJob < Scraper::JobBase
include Domain::StaticFileJobHelper
queue_as :static_file
discard_on ActiveJob::DeserializationError
sig { override.returns(Symbol) }
def self.http_factory_method

View File

@@ -1,6 +1,7 @@
# typed: strict
class Domain::UserAvatarJob < Scraper::JobBase
queue_as :static_file
discard_on ActiveJob::DeserializationError
sig { override.returns(Symbol) }
def self.http_factory_method
@@ -43,6 +44,10 @@ class Domain::UserAvatarJob < Scraper::JobBase
end
end
ensure
avatar.save! if avatar
if avatar
avatar.save!
user = avatar.user
user.touch if user
end
end
end

7
app/lib/bluesky/graph.rb Normal file
View File

@@ -0,0 +1,7 @@
# typed: strict
# frozen_string_literal: true
module Bluesky
module Graph
end
end

View File

@@ -0,0 +1,31 @@
# typed: strict
# frozen_string_literal: true
module Bluesky
module Graph
class Subject < T::ImmutableStruct
extend T::Sig
include T::Struct::ActsAsComparable
const :did, String
const :handle, String
const :display_name, T.nilable(String)
const :description, T.nilable(String)
const :avatar, T.nilable(String)
const :indexed_at, T.nilable(Time)
const :created_at, T.nilable(Time)
sig { params(json: T::Hash[String, T.untyped]).returns(Subject) }
def self.from_json(json)
new(
did: json["did"],
handle: json["handle"],
display_name: json["displayName"],
description: json["description"],
avatar: json["avatar"],
indexed_at: (ia = json["indexedAt"]) && Time.zone.parse(ia),
created_at: (ca = json["createdAt"]) && Time.zone.parse(ca),
)
end
end
end
end

View File

@@ -127,6 +127,16 @@ class Domain::User < ReduxApplicationRecord
has_many :followed_users, through: :user_user_follows_from, source: :to
has_many :followed_by_users, through: :user_user_follows_to, source: :from
has_many :follows_scans,
-> { where(kind: "follows").order(created_at: :asc) },
inverse_of: :user,
class_name: "Domain::UserJobEvent::FollowScan"
has_many :followed_by_scans,
-> { where(kind: "followed_by").order(created_at: :asc) },
inverse_of: :user,
class_name: "Domain::UserJobEvent::FollowScan"
sig { params(klass: T.class_of(Domain::Post)).void }
def self.has_created_posts!(klass)
self.class_has_created_posts = klass

View File

@@ -8,6 +8,9 @@ class Domain::User::BlueskyUser < Domain::User
has_created_posts! Domain::Post::BlueskyPost
# TODO - when we scrape liked posts, add this back in
# has_faved_posts! Domain::Post::BlueskyPost
#
has_followed_users! Domain::User::BlueskyUser
has_followed_by_users! Domain::User::BlueskyUser
belongs_to :last_scan_log_entry, class_name: "HttpLogEntry", optional: true
belongs_to :last_posts_scan_log_entry,

View File

@@ -6,5 +6,5 @@ class Domain::UserJobEvent < ReduxApplicationRecord
abstract!
self.abstract_class = true
belongs_to :user, class_name: Domain::User.name
belongs_to :user, class_name: "Domain::User"
end

View File

@@ -0,0 +1,32 @@
# typed: strict
# frozen_string_literal: true
class Domain::UserJobEvent::FollowScan < Domain::UserJobEvent
self.table_name = "domain_user_job_event_follow_scans"
belongs_to :log_entry, class_name: "HttpLogEntry", optional: true
enum :state,
{ running: "running", error: "error", completed: "completed" },
prefix: true
enum :kind, { followed_by: "followed_by", follows: "follows" }, prefix: true
validates :state,
presence: true,
inclusion: {
in: %w[running error completed],
}
validates :kind, presence: true, inclusion: { in: %w[followed_by follows] }
validates :started_at, presence: true
validates :completed_at, presence: true, unless: :state_running?
validates :log_entry, presence: true, if: :state_completed?
before_validation do
self.state ||= "running" if new_record?
self.started_at ||= Time.current if new_record?
end
sig { params(json_attributes: T::Hash[Symbol, T.untyped]).void }
def update_json_attributes!(json_attributes)
self.json_attributes = json_attributes.merge(self.json_attributes)
save!
end
end

View File

@@ -0,0 +1,4 @@
class Domain::UserJobEvent::PostsScan < Domain::UserJobEvent
self.table_name = "domain_user_job_event_posts_scans"
belongs_to :log_entry, class_name: "HttpLogEntry", optional: true
end

View File

@@ -0,0 +1,4 @@
class Domain::UserJobEvent::ProfileScan < Domain::UserJobEvent
self.table_name = "domain_user_job_event_profile_scans"
belongs_to :log_entry, class_name: "HttpLogEntry", optional: true
end

View File

@@ -64,7 +64,7 @@ class ReduxApplicationRecord < ActiveRecord::Base
end
after_save do
T.bind(self, ReduxApplicationRecord)
# T.bind(self, ReduxApplicationRecord)
@after_save_deferred_jobs ||=
T.let([], T.nilable(T::Array[[DeferredJob, T.nilable(Scraper::JobBase)]]))

View File

@@ -1,14 +1,14 @@
<div class="flex items-center gap-2">
<% if user.display_name.present? %>
<span class="text-slate-800 text-lg font-semibold">
<% if user.display_name.present? %>
<div class="flex flex-col mb-1">
<span class="text-slate-800 text-lg font-semibold leading-none">
<%= user.display_name %>
</span>
<span class="text-sm font-normal text-slate-500" title="<%= user.did %>">
@<%= user.handle %>
</span>
<% else %>
<span class="text-slate-800 text-lg font-semibold" title="<%= user.did %>">
<%= user.handle %>
</span>
<% end %>
</div>
</div>
<% else %>
<span class="text-slate-800 text-lg font-semibold" title="<%= user.did %>">
<%= user.handle %>
</span>
<% end %>

View File

@@ -6,12 +6,12 @@
<% case @index_type %>
<% when :followed_by %>
<%= pluralize(@users.total_count, "user") %> following
<%= link_to @user.name,
<%= link_to @user.name_for_view,
domain_user_following_path(@user),
class: "text-blue-600 hover:underline" %>
<% when :following %>
<%= pluralize(@users.total_count, "user") %> followed by
<%= link_to @user.name,
<%= link_to @user.name_for_view,
domain_user_following_path(@user),
class: "text-blue-600 hover:underline" %>
<% when :users_faving_post %>
@@ -36,17 +36,21 @@
<% if user.avatar&.log_entry.present? %>
<%= image_tag domain_user_avatar_img_src_path(user.avatar, thumb: "64-avatar"),
class: "h-12 w-12 rounded-md border object-cover",
alt: user.name %>
alt: user.name_for_view %>
<% else %>
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-slate-200"
>
<i class="bi bi-person text-slate-400"></i>
<i class="fa-solid fa-user text-slate-400"></i>
</div>
<% end %>
<div class="min-w-0">
<div class="font-medium text-slate-900"><%= user.name %></div>
<div class="text-sm text-slate-500">@<%= user.url_name %></div>
<div class="font-medium text-slate-900"><%= user.name_for_view %></div>
<% if user.is_a?(Domain::User::BlueskyUser) %>
<div class="text-sm text-slate-500">@<%= user.handle %></div>
<% elsif user.is_a?(Domain::User::FaUse) %>
<div class="text-sm text-slate-500">@<%= user.url_name %></div>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,17 @@
# typed: strict
# frozen_string_literal: true
class CreateDomainUserJobEventProfileScans < ActiveRecord::Migration[7.2]
sig { void }
def change
create_table :domain_user_job_event_profile_scans do |t|
t.references :user, null: false, foreign_key: { to_table: :domain_users }
t.references :log_entry,
foreign_key: {
to_table: :http_log_entries,
},
index: false
t.jsonb :json_attributes, null: false, default: {}
t.timestamps
end
end
end

View File

@@ -0,0 +1,20 @@
# typed: strict
# frozen_string_literal: true
class CreateDomainUserJobEventPostsScans < ActiveRecord::Migration[7.2]
sig { void }
def change
create_table :domain_user_job_event_posts_scans do |t|
t.references :user, null: false, foreign_key: { to_table: :domain_users }
t.references :log_entry,
foreign_key: {
to_table: :http_log_entries,
},
index: false
t.jsonb :json_attributes, null: false, default: {}
t.integer :total_posts_seen
t.integer :new_posts_seen
t.timestamps
end
end
end

View File

@@ -0,0 +1,18 @@
class CreateDomainUserJobEventFollowScans < ActiveRecord::Migration[7.2]
def change
create_table :domain_user_job_event_follow_scans do |t|
t.string :kind, null: false
t.string :state, null: false, default: "running"
t.datetime :started_at, null: false
t.datetime :completed_at
t.references :user, null: false, foreign_key: { to_table: :domain_users }
t.references :log_entry,
foreign_key: {
to_table: :http_log_entries,
},
index: false
t.jsonb :json_attributes, null: false, default: {}
t.timestamps
end
end
end

View File

@@ -0,0 +1,9 @@
# typed: strict
# frozen_string_literal: true
class AddRemovedAtToUserUserFollow < ActiveRecord::Migration[7.2]
sig { void }
def change
add_column :domain_user_user_follows, :removed_at, :datetime
end
end

View File

@@ -0,0 +1,7 @@
# typed: strict
class AddFollowedAtToUserUserFollow < ActiveRecord::Migration[7.2]
sig { void }
def change
add_column :domain_user_user_follows, :followed_at, :datetime
end
end

View File

@@ -1816,6 +1816,111 @@ CREATE SEQUENCE public.domain_user_job_event_add_tracked_objects_id_seq
ALTER SEQUENCE public.domain_user_job_event_add_tracked_objects_id_seq OWNED BY public.domain_user_job_event_add_tracked_objects.id;
--
-- Name: domain_user_job_event_follow_scans; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.domain_user_job_event_follow_scans (
id bigint NOT NULL,
kind character varying NOT NULL,
state character varying DEFAULT 'running'::character varying NOT NULL,
started_at timestamp(6) without time zone NOT NULL,
completed_at timestamp(6) without time zone,
user_id bigint NOT NULL,
log_entry_id bigint,
json_attributes jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
);
--
-- Name: domain_user_job_event_follow_scans_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.domain_user_job_event_follow_scans_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: domain_user_job_event_follow_scans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.domain_user_job_event_follow_scans_id_seq OWNED BY public.domain_user_job_event_follow_scans.id;
--
-- Name: domain_user_job_event_posts_scans; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.domain_user_job_event_posts_scans (
id bigint NOT NULL,
user_id bigint NOT NULL,
log_entry_id bigint,
json_attributes jsonb DEFAULT '{}'::jsonb NOT NULL,
total_posts_seen integer,
new_posts_seen integer,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
);
--
-- Name: domain_user_job_event_posts_scans_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.domain_user_job_event_posts_scans_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: domain_user_job_event_posts_scans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.domain_user_job_event_posts_scans_id_seq OWNED BY public.domain_user_job_event_posts_scans.id;
--
-- Name: domain_user_job_event_profile_scans; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.domain_user_job_event_profile_scans (
id bigint NOT NULL,
user_id bigint NOT NULL,
log_entry_id bigint,
json_attributes jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
);
--
-- Name: domain_user_job_event_profile_scans_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.domain_user_job_event_profile_scans_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: domain_user_job_event_profile_scans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.domain_user_job_event_profile_scans_id_seq OWNED BY public.domain_user_job_event_profile_scans.id;
--
-- Name: domain_user_post_creations; Type: TABLE; Schema: public; Owner: -
--
@@ -1918,7 +2023,9 @@ CREATE TABLE public.domain_user_user_follow_to_factors (
CREATE TABLE public.domain_user_user_follows (
from_id bigint NOT NULL,
to_id bigint NOT NULL
to_id bigint NOT NULL,
removed_at timestamp(6) without time zone,
followed_at timestamp(6) without time zone
);
@@ -3187,6 +3294,27 @@ ALTER TABLE ONLY public.domain_user_avatars ALTER COLUMN id SET DEFAULT nextval(
ALTER TABLE ONLY public.domain_user_job_event_add_tracked_objects ALTER COLUMN id SET DEFAULT nextval('public.domain_user_job_event_add_tracked_objects_id_seq'::regclass);
--
-- Name: domain_user_job_event_follow_scans id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_follow_scans ALTER COLUMN id SET DEFAULT nextval('public.domain_user_job_event_follow_scans_id_seq'::regclass);
--
-- Name: domain_user_job_event_posts_scans id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_posts_scans ALTER COLUMN id SET DEFAULT nextval('public.domain_user_job_event_posts_scans_id_seq'::regclass);
--
-- Name: domain_user_job_event_profile_scans id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_profile_scans ALTER COLUMN id SET DEFAULT nextval('public.domain_user_job_event_profile_scans_id_seq'::regclass);
--
-- Name: domain_user_search_names id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -3451,6 +3579,30 @@ ALTER TABLE ONLY public.domain_user_job_event_add_tracked_objects
ADD CONSTRAINT domain_user_job_event_add_tracked_objects_pkey PRIMARY KEY (id);
--
-- Name: domain_user_job_event_follow_scans domain_user_job_event_follow_scans_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_follow_scans
ADD CONSTRAINT domain_user_job_event_follow_scans_pkey PRIMARY KEY (id);
--
-- Name: domain_user_job_event_posts_scans domain_user_job_event_posts_scans_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_posts_scans
ADD CONSTRAINT domain_user_job_event_posts_scans_pkey PRIMARY KEY (id);
--
-- Name: domain_user_job_event_profile_scans domain_user_job_event_profile_scans_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_profile_scans
ADD CONSTRAINT domain_user_job_event_profile_scans_pkey PRIMARY KEY (id);
--
-- Name: domain_user_search_names domain_user_search_names_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -4537,6 +4689,27 @@ CREATE INDEX index_domain_user_avatars_on_user_id ON public.domain_user_avatars
CREATE INDEX index_domain_user_job_event_add_tracked_objects_on_user_id ON public.domain_user_job_event_add_tracked_objects USING btree (user_id);
--
-- Name: index_domain_user_job_event_follow_scans_on_user_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_user_job_event_follow_scans_on_user_id ON public.domain_user_job_event_follow_scans USING btree (user_id);
--
-- Name: index_domain_user_job_event_posts_scans_on_user_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_user_job_event_posts_scans_on_user_id ON public.domain_user_job_event_posts_scans USING btree (user_id);
--
-- Name: index_domain_user_job_event_profile_scans_on_user_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_user_job_event_profile_scans_on_user_id ON public.domain_user_job_event_profile_scans USING btree (user_id);
--
-- Name: index_domain_user_post_creations_on_post_id_and_user_id; Type: INDEX; Schema: public; Owner: -
--
@@ -5573,6 +5746,14 @@ ALTER TABLE ONLY public.domain_post_group_joins
ADD CONSTRAINT fk_rails_22154fb920 FOREIGN KEY (post_id) REFERENCES public.domain_posts(id);
--
-- Name: domain_user_job_event_profile_scans fk_rails_26cf4d0529; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_profile_scans
ADD CONSTRAINT fk_rails_26cf4d0529 FOREIGN KEY (log_entry_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_twitter_medias fk_rails_278c1d09f0; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5597,6 +5778,14 @@ ALTER TABLE ONLY public.domain_users_inkbunny_aux
ADD CONSTRAINT fk_rails_304ea0307f FOREIGN KEY (base_table_id) REFERENCES public.domain_users(id);
--
-- Name: domain_user_job_event_follow_scans fk_rails_3641cc46f0; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_follow_scans
ADD CONSTRAINT fk_rails_3641cc46f0 FOREIGN KEY (log_entry_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_posts_ib_aux fk_rails_3762390d41; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5709,6 +5898,14 @@ ALTER TABLE ONLY public.domain_user_search_names
ADD CONSTRAINT fk_rails_8475fe75b5 FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
--
-- Name: domain_user_job_event_posts_scans fk_rails_94b2c9f6fb; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_posts_scans
ADD CONSTRAINT fk_rails_94b2c9f6fb FOREIGN KEY (log_entry_id) REFERENCES public.http_log_entries(id);
--
-- Name: good_job_execution_log_lines_collections fk_rails_98c288034f; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5773,6 +5970,22 @@ ALTER TABLE ONLY public.domain_posts_ib_aux
ADD CONSTRAINT fk_rails_b94b311254 FOREIGN KEY (base_table_id) REFERENCES public.domain_posts(id);
--
-- Name: domain_user_job_event_profile_scans fk_rails_b9a4299a45; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_profile_scans
ADD CONSTRAINT fk_rails_b9a4299a45 FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
--
-- Name: domain_user_job_event_posts_scans fk_rails_ba009ed77e; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_posts_scans
ADD CONSTRAINT fk_rails_ba009ed77e FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
--
-- Name: domain_posts_fa_aux fk_rails_be2be2e955; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5853,6 +6066,14 @@ ALTER TABLE ONLY public.domain_posts_e621_aux
ADD CONSTRAINT fk_rails_d691739802 FOREIGN KEY (caused_by_entry_id) REFERENCES public.http_log_entries(id);
--
-- Name: domain_user_job_event_follow_scans fk_rails_ea2f8b74ab; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_user_job_event_follow_scans
ADD CONSTRAINT fk_rails_ea2f8b74ab FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
--
-- Name: domain_post_group_joins fk_rails_eddd0a9390; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -5892,6 +6113,11 @@ ALTER TABLE ONLY public.domain_twitter_tweets
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20250814165718'),
('20250814152837'),
('20250813190947'),
('20250813185746'),
('20250813185603'),
('20250813000020'),
('20250812215907'),
('20250812214902'),

View File

@@ -0,0 +1,27 @@
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Domain::Bluesky::Job::ScanUserFollowsJob`.
# Please instead update this file by running `bin/tapioca dsl Domain::Bluesky::Job::ScanUserFollowsJob`.
class Domain::Bluesky::Job::ScanUserFollowsJob
sig { returns(ColorLogger) }
def logger; end
class << self
sig { returns(ColorLogger) }
def logger; end
sig do
params(
args: T::Hash[::Symbol, T.untyped],
block: T.nilable(T.proc.params(job: Domain::Bluesky::Job::ScanUserFollowsJob).void)
).returns(T.any(Domain::Bluesky::Job::ScanUserFollowsJob, FalseClass))
end
def perform_later(args, &block); end
sig { params(args: T::Hash[::Symbol, T.untyped]).returns(T.untyped) }
def perform_now(args); end
end
end

View File

@@ -491,6 +491,20 @@ class Domain::User
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
def favs_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def followed_by_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def followed_by_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def followed_by_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_user_ids; end
@@ -519,6 +533,20 @@ class Domain::User
sig { params(value: T::Enumerable[::Domain::User]).void }
def followed_users=(value); end
sig { returns(T::Array[T.untyped]) }
def follows_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def follows_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def follows_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def follows_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def post_ids; end

View File

@@ -591,18 +591,32 @@ class Domain::User::BlueskyUser
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
def favs_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def followed_by_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def followed_by_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def followed_by_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_user_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def followed_by_user_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_users, through: :user_user_follows_to`.
# This method is created by ActiveRecord on the `Domain::User::BlueskyUser` class because it declared `has_many :followed_by_users, through: :user_user_follows_to`.
# 🔗 [Rails guide for `has_many_through` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association)
sig { returns(::Domain::User::PrivateCollectionProxy) }
sig { returns(::Domain::User::BlueskyUser::PrivateCollectionProxy) }
def followed_by_users; end
sig { params(value: T::Enumerable[::Domain::User]).void }
sig { params(value: T::Enumerable[::Domain::User::BlueskyUser]).void }
def followed_by_users=(value); end
sig { returns(T::Array[T.untyped]) }
@@ -611,14 +625,28 @@ class Domain::User::BlueskyUser
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def followed_user_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_users, through: :user_user_follows_from`.
# This method is created by ActiveRecord on the `Domain::User::BlueskyUser` class because it declared `has_many :followed_users, through: :user_user_follows_from`.
# 🔗 [Rails guide for `has_many_through` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association)
sig { returns(::Domain::User::PrivateCollectionProxy) }
sig { returns(::Domain::User::BlueskyUser::PrivateCollectionProxy) }
def followed_users; end
sig { params(value: T::Enumerable[::Domain::User]).void }
sig { params(value: T::Enumerable[::Domain::User::BlueskyUser]).void }
def followed_users=(value); end
sig { returns(T::Array[T.untyped]) }
def follows_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def follows_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def follows_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def follows_scans=(value); end
sig { returns(T.nilable(::HttpLogEntry)) }
def last_posts_scan_log_entry; end

View File

@@ -556,6 +556,20 @@ class Domain::User::E621User
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
def favs_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def followed_by_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def followed_by_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def followed_by_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_user_ids; end
@@ -584,6 +598,20 @@ class Domain::User::E621User
sig { params(value: T::Enumerable[::Domain::User]).void }
def followed_users=(value); end
sig { returns(T::Array[T.untyped]) }
def follows_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def follows_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def follows_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def follows_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def post_ids; end

View File

@@ -595,6 +595,20 @@ class Domain::User::FaUser
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
def favs_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def followed_by_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def followed_by_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def followed_by_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_user_ids; end
@@ -623,6 +637,20 @@ class Domain::User::FaUser
sig { params(value: T::Enumerable[::Domain::User::FaUser]).void }
def followed_users=(value); end
sig { returns(T::Array[T.untyped]) }
def follows_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def follows_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def follows_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def follows_scans=(value); end
sig { returns(T.nilable(::HttpLogEntry)) }
def last_gallery_page_log_entry; end

View File

@@ -567,6 +567,20 @@ class Domain::User::InkbunnyUser
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
def favs_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def followed_by_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def followed_by_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def followed_by_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_user_ids; end
@@ -595,6 +609,20 @@ class Domain::User::InkbunnyUser
sig { params(value: T::Enumerable[::Domain::User]).void }
def followed_users=(value); end
sig { returns(T::Array[T.untyped]) }
def follows_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def follows_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def follows_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def follows_scans=(value); end
sig { returns(T.nilable(::DomainUsersInkbunnyAux)) }
def inkbunny_aux; end

View File

@@ -680,6 +680,20 @@ class Domain::User::SofurryUser
sig { params(value: T::Enumerable[::Domain::PostGroup::SofurryFolder]).void }
def folders=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def followed_by_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def followed_by_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def followed_by_scans=(value); end
sig { returns(T::Array[T.untyped]) }
def followed_by_user_ids; end
@@ -708,6 +722,20 @@ class Domain::User::SofurryUser
sig { params(value: T::Enumerable[::Domain::User]).void }
def followed_users=(value); end
sig { returns(T::Array[T.untyped]) }
def follows_scan_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def follows_scan_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
def follows_scans; end
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
def follows_scans=(value); end
sig { returns(T.nilable(::HttpLogEntry)) }
def last_scan_log_entry; end

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -646,6 +646,61 @@ class Domain::UserUserFollow
end
module GeneratedAttributeMethods
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def followed_at; end
sig { params(value: T.nilable(::ActiveSupport::TimeWithZone)).returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def followed_at=(value); end
sig { returns(T::Boolean) }
def followed_at?; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def followed_at_before_last_save; end
sig { returns(T.untyped) }
def followed_at_before_type_cast; end
sig { returns(T::Boolean) }
def followed_at_came_from_user?; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def followed_at_change; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def followed_at_change_to_be_saved; end
sig do
params(
from: T.nilable(::ActiveSupport::TimeWithZone),
to: T.nilable(::ActiveSupport::TimeWithZone)
).returns(T::Boolean)
end
def followed_at_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def followed_at_in_database; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def followed_at_previous_change; end
sig do
params(
from: T.nilable(::ActiveSupport::TimeWithZone),
to: T.nilable(::ActiveSupport::TimeWithZone)
).returns(T::Boolean)
end
def followed_at_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def followed_at_previously_was; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def followed_at_was; end
sig { void }
def followed_at_will_change!; end
sig { returns(T.nilable(::Integer)) }
def from_id; end
@@ -756,15 +811,82 @@ class Domain::UserUserFollow
sig { void }
def id_will_change!; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def removed_at; end
sig { params(value: T.nilable(::ActiveSupport::TimeWithZone)).returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def removed_at=(value); end
sig { returns(T::Boolean) }
def removed_at?; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def removed_at_before_last_save; end
sig { returns(T.untyped) }
def removed_at_before_type_cast; end
sig { returns(T::Boolean) }
def removed_at_came_from_user?; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def removed_at_change; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def removed_at_change_to_be_saved; end
sig do
params(
from: T.nilable(::ActiveSupport::TimeWithZone),
to: T.nilable(::ActiveSupport::TimeWithZone)
).returns(T::Boolean)
end
def removed_at_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def removed_at_in_database; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def removed_at_previous_change; end
sig do
params(
from: T.nilable(::ActiveSupport::TimeWithZone),
to: T.nilable(::ActiveSupport::TimeWithZone)
).returns(T::Boolean)
end
def removed_at_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def removed_at_previously_was; end
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
def removed_at_was; end
sig { void }
def removed_at_will_change!; end
sig { void }
def restore_followed_at!; end
sig { void }
def restore_from_id!; end
sig { void }
def restore_id!; end
sig { void }
def restore_removed_at!; end
sig { void }
def restore_to_id!; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def saved_change_to_followed_at; end
sig { returns(T::Boolean) }
def saved_change_to_followed_at?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_from_id; end
@@ -779,6 +901,12 @@ class Domain::UserUserFollow
sig { returns(T::Boolean) }
def saved_change_to_id?; end
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
def saved_change_to_removed_at; end
sig { returns(T::Boolean) }
def saved_change_to_removed_at?; end
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
def saved_change_to_to_id; end
@@ -830,12 +958,18 @@ class Domain::UserUserFollow
sig { void }
def to_id_will_change!; end
sig { returns(T::Boolean) }
def will_save_change_to_followed_at?; end
sig { returns(T::Boolean) }
def will_save_change_to_from_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_id?; end
sig { returns(T::Boolean) }
def will_save_change_to_removed_at?; end
sig { returns(T::Boolean) }
def will_save_change_to_to_id?; end
end

View File

@@ -92,7 +92,7 @@ class HttpClientMockHelpers
build(
:blob_file,
content_type: request[:content_type],
contents: request[:contents],
contents: request[:contents].dup,
),
)
log_entry.save!

View File

@@ -0,0 +1,248 @@
# typed: false
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Domain::Bluesky::Job::ScanUserFollowsJob do
include PerformJobHelpers
let(:user) do
create(
:domain_user_bluesky_user,
did: "did:plc:test123",
handle: "testuser.bsky.social",
)
end
describe "#perform" do
context "when fetching follows succeeds" do
let(:first_follows_response_body) do
{
"cursor" => "next_page_cursor",
"follows" => [
{
"did" => "did:plc:follow1",
"handle" => "follow1.bsky.social",
"displayName" => "Follow One",
"description" => "First follower",
"createdAt" => "2025-01-01T00:00:00Z",
},
{
"did" => "did:plc:follow2",
"handle" => "follow2.bsky.social",
"displayName" => "Follow Two",
"description" => "Second follower",
"createdAt" => "2025-01-02T00:00:00Z",
},
],
}.to_json
end
let(:second_follows_response_body) do
{
"cursor" => nil,
"follows" => [
{
"did" => "did:plc:follow3",
"handle" => "follow3.bsky.social",
"displayName" => "Follow Three",
"description" => "Third follower",
"createdAt" => "2025-01-03T00:00:00Z",
},
],
}.to_json
end
let(:first_followers_response_body) do
{
"cursor" => nil,
"followers" => [
{
"did" => "did:plc:follow5",
"handle" => "follow5.bsky.social",
"displayName" => "Follow Five",
"description" => "Fifth follower",
"createdAt" => "2025-01-05T00:00:00Z",
},
],
}.to_json
end
let(:client_mock_config) do
[
{
uri:
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=#{user.did!}&limit=100",
status_code: 200,
content_type: "application/json",
contents: first_follows_response_body,
},
{
uri:
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=#{user.did!}&limit=100&cursor=next_page_cursor",
status_code: 200,
content_type: "application/json",
contents: second_follows_response_body,
caused_by_entry_idx: 0,
},
{
uri:
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=#{user.did!}&limit=100",
status_code: 200,
content_type: "application/json",
contents: first_followers_response_body,
caused_by_entry_idx: 0,
},
]
end
before do
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
# an existing follow that is in the follows scan
@follow2 =
create(
:domain_user_bluesky_user,
did: "did:plc:follow2",
handle: "follow2.bsky.social",
)
Domain::UserUserFollow.create!(from: user, to: @follow2)
# an existing follow that is not in the follows scan
@follow4 =
create(
:domain_user_bluesky_user,
did: "did:plc:follow4",
handle: "follow4.bsky.social",
)
Domain::UserUserFollow.create!(from: user, to: @follow4)
end
it "creates a follow scan event" do
perform_now({ user: user })
scan = user.follows_scans.last
expect(scan).to be_present
expect(scan.user).to eq(user)
expect(scan.log_entry).to eq(@log_entries[0])
ja = scan.json_attributes
expect(ja["num_created_users"]).to eq(2)
expect(ja["num_total_assocs"]).to eq(3)
expect(ja["num_existing_assocs"]).to eq(2)
expect(ja["num_new_assocs"]).to eq(2)
expect(ja["num_removed_assocs"]).to eq(1)
end
it "creates a followed_by scan event" do
perform_now({ user: user })
scan = user.followed_by_scans.last
expect(scan).to be_present
expect(scan.user).to eq(user)
expect(scan.log_entry).to eq(@log_entries[2])
ja = scan.json_attributes
expect(ja["num_created_users"]).to eq(1)
expect(ja["num_total_assocs"]).to eq(1)
expect(ja["num_existing_assocs"]).to eq(0)
expect(ja["num_new_assocs"]).to eq(1)
expect(ja["num_removed_assocs"]).to eq(0)
end
it "updates the user follows" do
expect do
perform_now({ user: user })
user.reload
end.to change { user.reload.user_user_follows_from_count }.from(2).to(4)
expect(user.user_user_follows_from.where(removed_at: nil).count).to eq(
3,
)
expect(
user.user_user_follows_from.where.not(removed_at: nil).pluck(:to_id),
).to eq([@follow4.id])
end
it "updates counter caches for all involved users" do
perform_now({ user: user })
user.reload
expect(user.user_user_follows_from_count).to eq(4)
expect(user.user_user_follows_to_count).to eq(1)
follow1 = Domain::User::BlueskyUser.find_by(did: "did:plc:follow1")
expect(follow1.user_user_follows_from_count).to eq(0)
expect(follow1.user_user_follows_to_count).to eq(1)
follow5 = Domain::User::BlueskyUser.find_by(did: "did:plc:follow5")
expect(follow5.user_user_follows_from_count).to eq(1)
expect(follow5.user_user_follows_to_count).to eq(0)
end
it "sets the followed_at attribute" do
perform_now({ user: user })
user.reload
follow1 = Domain::User::BlueskyUser.find_by(did: "did:plc:follow1")
follow1_assoc = user.user_user_follows_from.find_by(to: follow1)
expect(follow1_assoc.followed_at).to eq(
Time.parse("2025-01-01T00:00:00Z"),
)
expect(follow1_assoc.removed_at).to be_nil
follow4 = Domain::User::BlueskyUser.find_by(did: "did:plc:follow4")
follow4_assoc = user.user_user_follows_from.find_by(to: follow4)
expect(follow4_assoc.followed_at).to be_nil
expect(follow4_assoc.removed_at).to be_within(10.seconds).of(
Time.current,
)
end
end
context "when API returns an error" do
let(:error_response_body) { { "error" => "Not found" }.to_json }
let(:client_mock_config) do
[
{
uri:
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=#{user.did!}&limit=100",
status_code: 200,
content_type: "application/json",
contents: error_response_body,
},
]
end
before do
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
end
it "raises a fatal error" do
expect { perform_now({ user: user }) }.to raise_error(
/failed to get user follows/,
)
end
end
context "when API returns non-200 status" do
let(:client_mock_config) do
[
{
uri:
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=#{user.did!}&limit=100",
status_code: 404,
content_type: "application/json",
contents: "Not Found",
},
]
end
before do
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
end
it "raises a fatal error" do
expect { perform_now({ user: user }) }.to raise_error(
/failed to get user follows/,
)
end
end
end
end

View File

@@ -104,16 +104,16 @@ RSpec.describe Domain::Bluesky::Job::ScanUserJob do
expect(user.last_scan_log_entry).to eq(@log_entries.first)
end
it "enqueues ScanPostsJob for posts scanning" do
it "does not enqueue a ScanPostsJob" do
# Clear any existing enqueued jobs first
SpecUtil.clear_enqueued_jobs!
perform_now({ user: user })
# Check that ScanPostsJob was enqueued with correct arguments
# Check that ScanPostsJob was not enqueued
enqueued_jobs =
SpecUtil.enqueued_job_args(Domain::Bluesky::Job::ScanPostsJob)
expect(enqueued_jobs).to contain_exactly(hash_including(user: user))
expect(enqueued_jobs).to be_empty
end
it "creates avatar for user with pending state" do
@@ -288,7 +288,6 @@ RSpec.describe Domain::Bluesky::Job::ScanUserJob do
it "skips scanning if not due" do
expect(Scraper::ClientFactory.http_client_mock).not_to receive(:get)
perform_now({ user: user })
end
end