From 13742cc9e9ba23eb8c4e30fed48da95ab945a2eb Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 9 Aug 2026 09:35:46 +0200 Subject: [PATCH 01/16] feat: add reliable social connections and feed --- server/src/crate_server/context.gleam | 1 + server/src/crate_server/follow_index.gleam | 57 +++- .../crate_server/follow_index_postgres.gleam | 78 ++++- server/src/crate_server/graph_follows.gleam | 37 ++- .../crate_server/handlers/connections.gleam | 223 ++++++++++++++ server/src/crate_server/handlers/feed.gleam | 117 ++++++-- server/src/crate_server/handlers/graph.gleam | 125 ++++++-- .../crate_server/oauth/client_metadata.gleam | 3 + server/src/crate_server/router.gleam | 2 + server/src/crate_server/user_backfill.gleam | 29 +- server/test/connections_handler_test.gleam | 280 ++++++++++++++++++ server/test/feed_handler_test.gleam | 108 ++++++- server/test/follow_index_postgres_test.gleam | 2 + server/test/follow_index_test.gleam | 27 ++ server/test/graph_handler_test.gleam | 169 ++++++++++- server/test/oauth_test.gleam | 12 + server/test/user_backfill_test.gleam | 65 +++- web/css/15-misc.css | 11 +- web/css/21-connections.css | 81 +++++ web/src/crate_web.gleam | 13 +- web/src/crate_web/effects.gleam | 110 +++++-- web/src/crate_web/model.gleam | 58 ++++ web/src/crate_web/msg.gleam | 38 ++- web/src/crate_web/pages/connections.gleam | 172 +++++++++++ web/src/crate_web/pages/feed.gleam | 153 ++++++++-- web/src/crate_web/pages/settings.gleam | 22 +- web/src/crate_web/route.gleam | 11 +- web/src/crate_web/update.gleam | 18 +- web/src/crate_web/update/browse.gleam | 10 +- web/src/crate_web/update/common.gleam | 26 ++ web/src/crate_web/update/connections.gleam | 247 +++++++++++++++ web/src/crate_web/update/feed.gleam | 64 +++- web/src/crate_web/update/routing.gleam | 45 ++- web/src/crate_web/update/session.gleam | 14 + web/src/crate_web/view.gleam | 52 +++- web/test/connections_test.gleam | 279 +++++++++++++++++ web/test/feed_test.gleam | 136 ++++++++- web/test/login_test.gleam | 14 +- web/test/public_crate_test.gleam | 18 +- web/test/support.gleam | 6 + 40 files changed, 2744 insertions(+), 189 deletions(-) create mode 100644 server/src/crate_server/handlers/connections.gleam create mode 100644 server/test/connections_handler_test.gleam create mode 100644 web/css/21-connections.css create mode 100644 web/src/crate_web/pages/connections.gleam create mode 100644 web/src/crate_web/update/connections.gleam create mode 100644 web/test/connections_test.gleam diff --git a/server/src/crate_server/context.gleam b/server/src/crate_server/context.gleam index 37583f2..8c9a8e0 100644 --- a/server/src/crate_server/context.gleam +++ b/server/src/crate_server/context.gleam @@ -94,6 +94,7 @@ fn error_name(status: Int) -> String { case status { 400 -> "InvalidRequest" 401 -> "AuthenticationRequired" + 403 -> "Forbidden" 404 -> "NotFound" 409 -> "Conflict" 413 -> "PayloadTooLarge" diff --git a/server/src/crate_server/follow_index.gleam b/server/src/crate_server/follow_index.gleam index 583cde5..11d3a6b 100644 --- a/server/src/crate_server/follow_index.gleam +++ b/server/src/crate_server/follow_index.gleam @@ -13,6 +13,7 @@ import gleam/option.{type Option} import gleam/otp/actor import gleam/result import gleam/set.{type Set} +import gleam/string /// One graph.follow record, keyed by its own at-uri. The unfollow path's rkey /// is derived from that uri rather than stored beside it, so they cannot @@ -32,8 +33,18 @@ pub type EdgeOps { /// The dids `did` follows. Empty is ambiguous alone; pair with /// `SeenOps.has_seen`. following: fn(String) -> List(String), + /// The dids that follow `did`. This reverse lookup is appview-derived and + /// may be incomplete until every known actor has been backfilled. + followers: fn(String) -> List(String), /// The unfollow path's lookup, served without a live `listRecords`. find: fn(String, String) -> Option(Follow), + /// Every edge in a logical relationship. Legacy repos may contain + /// duplicates, so cleanup callers need all matching records. + find_all: fn(String, String) -> List(Follow), + /// Replace every indexed edge authored by `did` with a complete PDS read. + /// The operation is atomic for the in-memory backend and transactional for + /// the Postgres backend, so stale rows cannot survive a successful read. + replace_for_did: fn(String, List(Follow)) -> Nil, /// Total indexed edges: a cheap completeness figure for `/debug/index`. count: fn() -> Int, ) @@ -61,7 +72,10 @@ type Msg { Delete(String) DeleteForDid(String) Following(String, Subject(List(String))) + Followers(String, Subject(List(String))) Find(String, String, Subject(Option(Follow))) + FindAll(String, String, Subject(List(Follow))) + ReplaceForDid(String, List(Follow)) Count(Subject(Int)) HasSeen(String, Subject(Bool)) MarkSeen(String) @@ -80,9 +94,16 @@ pub fn start() -> Result(Store, actor.StartError) { delete: fn(follow_uri) { process.send(subject, Delete(follow_uri)) }, delete_for_did: fn(did) { process.send(subject, DeleteForDid(did)) }, following: fn(did) { parallel.ask(subject, Following(did, _), or: []) }, + followers: fn(did) { parallel.ask(subject, Followers(did, _), or: []) }, find: fn(did, subject_did) { parallel.ask(subject, Find(did, subject_did, _), or: option.None) }, + find_all: fn(did, subject_did) { + parallel.ask(subject, FindAll(did, subject_did, _), or: []) + }, + replace_for_did: fn(did, follows) { + process.send(subject, ReplaceForDid(did, follows)) + }, count: fn() { parallel.ask(subject, Count, or: 0) }, ), seen: SeenOps( @@ -115,7 +136,18 @@ fn handle(state: State, msg: Msg) -> actor.Next(State, Msg) { reply, dict.values(state.follows) |> list.filter(fn(f) { f.did == did }) - |> list.map(fn(f) { f.subject }), + |> list.map(fn(f) { f.subject }) + |> list.sort(string.compare), + ) + actor.continue(state) + } + Followers(did, reply) -> { + process.send( + reply, + dict.values(state.follows) + |> list.filter(fn(f) { f.subject == did }) + |> list.map(fn(f) { f.did }) + |> list.sort(string.compare), ) actor.continue(state) } @@ -123,11 +155,32 @@ fn handle(state: State, msg: Msg) -> actor.Next(State, Msg) { process.send( reply, dict.values(state.follows) - |> list.find(fn(f) { f.did == did && f.subject == subject_did }) + |> list.filter(fn(f) { f.did == did && f.subject == subject_did }) + |> list.sort(fn(a, b) { string.compare(a.follow_uri, b.follow_uri) }) + |> list.first |> option.from_result, ) actor.continue(state) } + FindAll(did, subject_did, reply) -> { + process.send( + reply, + dict.values(state.follows) + |> list.filter(fn(f) { f.did == did && f.subject == subject_did }) + |> list.sort(fn(a, b) { string.compare(a.follow_uri, b.follow_uri) }), + ) + actor.continue(state) + } + ReplaceForDid(did, follows) -> { + let retained = + dict.filter(state.follows, fn(_uri, follow) { follow.did != did }) + let indexed = + follows + |> list.fold(retained, fn(edges, follow) { + dict.insert(edges, follow.follow_uri, follow) + }) + actor.continue(State(..state, follows: indexed)) + } Count(reply) -> { process.send(reply, dict.size(state.follows)) actor.continue(state) diff --git a/server/src/crate_server/follow_index_postgres.gleam b/server/src/crate_server/follow_index_postgres.gleam index 68a157d..8a34840 100644 --- a/server/src/crate_server/follow_index_postgres.gleam +++ b/server/src/crate_server/follow_index_postgres.gleam @@ -10,9 +10,11 @@ import crate_server/follow_index.{ } import crate_server/provenance import gleam/dynamic/decode +import gleam/list import gleam/option.{type Option} import gleam/result import pog +import wisp const columns = "follow_uri, did, subject, created_at" @@ -25,6 +27,9 @@ pub fn table_store(conn: pog.Connection) -> Result(Store, String) { delete_for_did: fn(did) { delete_for_did(conn, did) }, following: fn(did) { following(conn, did) }, find: fn(did, subject) { find(conn, did, subject) }, + find_all: fn(did, subject) { find_all(conn, did, subject) }, + replace_for_did: fn(did, follows) { replace_for_did(conn, did, follows) }, + followers: fn(did) { followers(conn, did) }, count: fn() { count(conn) }, ), seen: SeenOps(has_seen: fn(did) { has_seen(conn, did) }, mark_seen: fn(did) { @@ -62,6 +67,13 @@ fn migrate(conn: pog.Connection) -> Result(Nil, String) { |> pog.execute(conn) |> result.replace_error("follow_edges did index migration failed"), ) + use _ <- result.try( + pog.query( + "create index if not exists follow_edges_subject_idx on follow_edges (subject)", + ) + |> pog.execute(conn) + |> result.replace_error("follow_edges subject index migration failed"), + ) pog.query( "create table if not exists follow_index_seen ( did text primary key, @@ -118,7 +130,7 @@ fn following(conn: pog.Connection, did: String) -> List(String) { use subject <- decode.field("subject", decode.string) decode.success(subject) } - pog.query("select subject from follow_edges where did = $1") + pog.query("select subject from follow_edges where did = $1 order by subject") |> pog.parameter(pog.text(did)) |> pog.returning(row) |> db.all(conn) @@ -126,13 +138,75 @@ fn following(conn: pog.Connection, did: String) -> List(String) { fn find(conn: pog.Connection, did: String, subject: String) -> Option(Follow) { pog.query("select " <> columns <> " - from follow_edges where did = $1 and subject = $2 limit 1") + from follow_edges where did = $1 and subject = $2 + order by follow_uri limit 1") |> pog.parameter(pog.text(did)) |> pog.parameter(pog.text(subject)) |> pog.returning(row_decoder()) |> db.first(conn) } +fn find_all( + conn: pog.Connection, + did: String, + subject: String, +) -> List(Follow) { + pog.query("select " <> columns <> " + from follow_edges where did = $1 and subject = $2 + order by follow_uri") + |> pog.parameter(pog.text(did)) + |> pog.parameter(pog.text(subject)) + |> pog.returning(row_decoder()) + |> db.all(conn) +} + +fn replace_for_did( + conn: pog.Connection, + did: String, + follows: List(Follow), +) -> Nil { + let result = + pog.transaction(conn, fn(transaction) { + use _ <- result.try( + pog.query("delete from follow_edges where did = $1") + |> pog.parameter(pog.text(did)) + |> pog.execute(transaction), + ) + list.try_each(follows, fn(follow: Follow) { + pog.query( + "insert into follow_edges (follow_uri, did, subject, created_at) + values ($1, $2, $3, $4) + on conflict (follow_uri) do update set + did = excluded.did, + subject = excluded.subject, + created_at = excluded.created_at", + ) + |> pog.parameter(pog.text(follow.follow_uri)) + |> pog.parameter(pog.text(follow.did)) + |> pog.parameter(pog.text(follow.subject)) + |> pog.parameter(pog.text(follow.created_at)) + |> pog.execute(transaction) + }) + |> result.map(fn(_) { Nil }) + }) + case result { + Ok(_) -> Nil + Error(_) -> + wisp.log_warning("follow_edges reconciliation failed for " <> did) + } +} + +fn followers(conn: pog.Connection, subject: String) -> List(String) { + let row = { + use did <- decode.field("did", decode.string) + decode.success(did) + } + pog.query("select did from follow_edges where subject = $1 order by did") + |> pog.parameter(pog.text(subject)) + |> pog.returning(row) + |> db.all(conn) +} + fn count(conn: pog.Connection) -> Int { pog.query("select count(*)::int as count from follow_edges") |> db.count(conn) diff --git a/server/src/crate_server/graph_follows.gleam b/server/src/crate_server/graph_follows.gleam index b3d357a..dc29537 100644 --- a/server/src/crate_server/graph_follows.gleam +++ b/server/src/crate_server/graph_follows.gleam @@ -7,10 +7,13 @@ import crate/gen/graph/follow.{type GraphFollow, GraphFollow} import crate/storage.{type StoredItem} import crate_server/oauth/sessions.{type OauthSession} import crate_server/provenance +import gleam/bit_array +import gleam/crypto import gleam/dynamic/decode import gleam/list import gleam/option.{type Option} import gleam/result +import gleam/string pub fn load( client: Client, @@ -26,10 +29,10 @@ pub fn load( ) } -/// Create a follow record for `subject_did`; duplicates are the caller's -/// problem to guard against, per the lexicon's doc comment. Returns the record -/// written, so a caller mirroring it into the index stores the timestamp that -/// actually reached the PDS. +/// Create a follow record for `subject_did` at a deterministic rkey. Repeated +/// requests, including concurrent requests, therefore converge on one PDS +/// record through putRecord. Legacy random-rkey records remain readable and +/// are removed together by the unfollow handler. pub fn create( client: Client, session: OauthSession, @@ -37,25 +40,45 @@ pub fn create( ) -> Result(#(repo.CreatedRecord, GraphFollow), XrpcError) { let record = GraphFollow(created_at: provenance.now_rfc3339(), subject: subject_did) - repo.create_record( + repo.put_record( client, session.pds, session.access_token, session.did, follow.collection, + rkey_for_subject(subject_did), follow.encode_graph_follow(record), ) |> result.map(fn(created) { #(created, record) }) } +/// Rkeys are URL-safe and stable across clients, while the hash avoids +/// characters that AT Protocol rkey validation rejects in a DID. +pub fn rkey_for_subject(subject_did: String) -> String { + "f_" + <> bit_array.base64_url_encode( + crypto.hash(crypto.Sha256, bit_array.from_string(subject_did)), + False, + ) +} + /// The stored follow record for `subject_did`, if any. pub fn find( stored: List(StoredItem(GraphFollow)), subject_did: String, ) -> Option(StoredItem(GraphFollow)) { + find_all(stored, subject_did) |> list.first |> option.from_result +} + +/// All stored records for a subject, in a stable URI order. Stable ordering +/// makes retries and cleanup deterministic when a legacy repo has duplicates. +pub fn find_all( + stored: List(StoredItem(GraphFollow)), + subject_did: String, +) -> List(StoredItem(GraphFollow)) { stored - |> list.find(fn(s) { s.value.subject == subject_did }) - |> option.from_result + |> list.filter(fn(s) { s.value.subject == subject_did }) + |> list.sort(fn(a, b) { string.compare(a.uri, b.uri) }) } pub fn delete( diff --git a/server/src/crate_server/handlers/connections.gleam b/server/src/crate_server/handlers/connections.gleam new file mode 100644 index 0000000..92ab363 --- /dev/null +++ b/server/src/crate_server/handlers/connections.gleam @@ -0,0 +1,223 @@ +//// Read-only connections for the signed-in viewer. Following is authoritative +//// after one successful read from the viewer's PDS. Followers come from the +//// appview index and deliberately report incomplete until a global index +//// watermark exists. + +import crate/gen/graph/follow.{type GraphFollow} +import crate/storage.{type StoredItem} +import crate_server/context.{ + type Context, error_json, require_session, with_pds_client, +} +import crate_server/follow_index +import crate_server/graph_follows +import crate_server/identity_cache.{type Identity} +import crate_server/identity_resolver +import crate_server/oauth/sessions.{type OauthSession} +import crate_server/pagination +import gleam/dict.{type Dict} +import gleam/int +import gleam/json +import gleam/list +import gleam/option.{None, Some} +import gleam/result +import gleam/set +import gleam/string +import wisp.{type Request, type Response} + +type Direction { + Following + Followers +} + +type Connection { + Connection( + did: String, + viewer_follows: Bool, + follows_viewer: Bool, + mutual: Bool, + ) +} + +pub fn list_connections(req: Request, ctx: Context) -> Response { + use id, session <- require_session(req, ctx) + case direction(wisp.get_query(req)) { + Error(Nil) -> error_json(400, "expected direction=following or followers") + Ok(Following) -> list_following(req, ctx, id, session) + Ok(Followers) -> list_followers(req, ctx, session) + } +} + +fn list_following( + req: Request, + ctx: Context, + id: String, + session: OauthSession, +) -> Response { + use client, session <- with_pds_client(ctx, id, session) + case graph_follows.load(client, session) { + Error(_) -> error_json(502, "could not load your follows from PDS") + Ok(stored) -> { + let following = list.map(stored, fn(row) { row.value.subject }) + index_follows(ctx, session.did, stored) + respond(ctx, session.did, Following, following, True, wisp.get_query(req)) + } + } +} + +/// Followers are appview-indexed and can be read without reaching the +/// viewer's PDS. If that viewer has not had a complete following read yet, +/// relationship flags remain conservative and the response says so. +fn list_followers( + req: Request, + ctx: Context, + session: OauthSession, +) -> Response { + let known = ctx.follow_index.seen.has_seen(session.did) + let following = case known { + True -> ctx.follow_index.edges.following(session.did) + False -> [] + } + respond(ctx, session.did, Followers, following, known, wisp.get_query(req)) +} + +fn direction(query: List(#(String, String))) -> Result(Direction, Nil) { + case list.key_find(query, "direction") { + Ok("following") -> Ok(Following) + Ok("followers") -> Ok(Followers) + _ -> Error(Nil) + } +} + +fn index_follows( + ctx: Context, + did: String, + stored: List(StoredItem(GraphFollow)), +) -> Nil { + let follows = + stored + |> list.map(fn(row) { + follow_index.Follow( + follow_uri: row.uri, + did:, + subject: row.value.subject, + created_at: row.value.created_at, + ) + }) + ctx.follow_index.edges.replace_for_did(did, follows) + ctx.follow_index.seen.mark_seen(did) +} + +fn respond( + ctx: Context, + viewer_did: String, + direction: Direction, + following: List(String), + following_complete: Bool, + query: List(#(String, String)), +) -> Response { + let followers = + ctx.follow_index.edges.followers(viewer_did) + |> list.unique + |> list.sort(string.compare) + let following_set = set.from_list(following) + let follower_set = set.from_list(followers) + let items = case direction { + Following -> + following + |> list.unique + |> list.sort(string.compare) + |> list.map(fn(did) { + connection(did, True, set.contains(follower_set, did)) + }) + Followers -> + followers + |> list.map(fn(did) { + connection(did, set.contains(following_set, did), True) + }) + } + respond_page(ctx, viewer_did, direction, items, following_complete, query) +} + +fn connection( + did: String, + viewer_follows: Bool, + follows_viewer: Bool, +) -> Connection { + Connection( + did:, + viewer_follows:, + follows_viewer:, + mutual: viewer_follows && follows_viewer, + ) +} + +fn respond_page( + ctx: Context, + session_did: String, + direction: Direction, + items: List(Connection), + following_complete: Bool, + query: List(#(String, String)), +) -> Response { + let cursor = list.key_find(query, "cursor") |> option.from_result + let limit = + list.key_find(query, "limit") + |> result.try(int.parse) + |> option.from_result + let #(page, next_cursor) = + pagination.page( + items, + fn(item) { item.did }, + cursor_namespace(direction, session_did), + cursor, + Some(option.unwrap(limit, pagination.default_limit)), + ) + let identities = + page + |> list.map(fn(item) { item.did }) + |> identity_resolver.resolve_many(ctx.atproto.identity, _) + let complete = case direction { + Following -> True + Followers -> False + } + let relationship_complete = case direction { + Following -> False + Followers -> following_complete + } + json.object([ + #("items", json.array(page, encode_connection(identities, _))), + #("complete", json.bool(complete)), + #("relationshipComplete", json.bool(relationship_complete)), + ..case next_cursor { + Some(cursor) -> [#("cursor", json.string(cursor))] + None -> [] + } + ]) + |> json.to_string + |> wisp.json_response(200) +} + +fn cursor_namespace(direction: Direction, viewer_did: String) -> String { + let prefix = case direction { + Following -> "connections-following-" + Followers -> "connections-followers-" + } + prefix <> string.replace(viewer_did, ":", "-") +} + +fn encode_connection( + identities: Dict(String, Identity), + item: Connection, +) -> json.Json { + let handle = case dict.get(identities, item.did) { + Ok(identity) -> identity.handle + Error(Nil) -> item.did + } + json.object([ + #("did", json.string(item.did)), + #("handle", json.string(handle)), + #("viewerFollows", json.bool(item.viewer_follows)), + #("followsViewer", json.bool(item.follows_viewer)), + #("mutual", json.bool(item.mutual)), + ]) +} diff --git a/server/src/crate_server/handlers/feed.gleam b/server/src/crate_server/handlers/feed.gleam index f7f0558..c5bba37 100644 --- a/server/src/crate_server/handlers/feed.gleam +++ b/server/src/crate_server/handlers/feed.gleam @@ -28,7 +28,12 @@ import wisp.{type Request, type Response} // (a bare did always does, e.g. "did:plc:...") would never round-trip. const following_namespace_prefix = "feed-following-" -const network_namespace = "feed-network" +const network_namespace_prefix = "feed-network-" + +type FeedMode { + Following + Network +} pub fn get_feed_skeleton(req: Request, ctx: Context) -> Response { use id, session <- require_session(req, ctx) @@ -88,15 +93,17 @@ fn seed_follows( case graph_follows.load(client, session) { Error(_) -> [] Ok(stored) -> { - stored - |> list.each(fn(s) { - ctx.follow_index.edges.upsert(follow_index.Follow( - follow_uri: s.uri, - did: session.did, - subject: s.value.subject, - created_at: s.value.created_at, - )) - }) + let follows = + stored + |> list.map(fn(s) { + follow_index.Follow( + follow_uri: s.uri, + did: session.did, + subject: s.value.subject, + created_at: s.value.created_at, + ) + }) + ctx.follow_index.edges.replace_for_did(session.did, follows) ctx.follow_index.seen.mark_seen(session.did) list.map(stored, fn(s) { s.value.subject }) } @@ -106,9 +113,9 @@ fn seed_follows( /// Followed-mode first; falls back to network-wide (unfiltered, viewer /// included -- simpler than re-excluding them from their own discovery feed) /// when the follow graph is empty, or when a first request (no cursor yet) -/// finds nothing from people the caller follows. A cursor already scoped to -/// one namespace never resumes the other: `pagination.page` restarts a -/// foreign-namespace cursor from the top of whichever list it's handed. +/// finds nothing from people the caller follows. A cursor selects the mode it +/// was minted for, so a network continuation never passes through followed +/// pagination. Invalid or foreign cursors restart a fresh followed request. fn resolve_page( adoptions: List(Adoption), followed: List(String), @@ -123,33 +130,81 @@ fn resolve_page( a.did != viewer_did && set.contains(followed_set, a.did) }) |> sort_desc - let #(followed_page, followed_cursor) = + let #(mode, mode_cursor) = mode_for_cursor(viewer_did, cursor) + case mode { + Network -> network_page(adoptions, viewer_did, mode_cursor, limit) + Following -> { + let #(followed_page, followed_cursor) = + pagination.page( + followed_rows, + fn(a: Adoption) { a.entry_uri }, + following_namespace(viewer_did), + mode_cursor, + limit, + ) + let needs_fallback = mode_cursor == None && list.is_empty(followed_page) + case needs_fallback { + True -> network_page(adoptions, viewer_did, None, limit) + False -> #( + feed_skeleton.collapse(followed_page), + followed_cursor, + False, + ) + } + } + } +} + +fn network_page( + adoptions: List(Adoption), + viewer_did: String, + cursor: Option(String), + limit: Option(Int), +) -> #(List(feed_gen.FeedItem), Option(String), Bool) { + let #(page, next_cursor) = pagination.page( - followed_rows, + sort_desc(adoptions), fn(a: Adoption) { a.entry_uri }, - following_namespace_prefix <> string.replace(viewer_did, ":", "-"), + network_namespace(viewer_did), cursor, limit, ) - let needs_fallback = - list.is_empty(followed) - || { cursor == None && list.is_empty(followed_page) } - case needs_fallback { - True -> { - let #(network_page, network_cursor) = - pagination.page( - sort_desc(adoptions), - fn(a: Adoption) { a.entry_uri }, - network_namespace, - cursor, - limit, - ) - #(feed_skeleton.collapse(network_page), network_cursor, True) + #(feed_skeleton.collapse(page), next_cursor, True) +} + +fn mode_for_cursor( + viewer_did: String, + cursor: Option(String), +) -> #(FeedMode, Option(String)) { + case cursor { + None -> #(Following, None) + Some(token) -> { + case pagination.decode_cursor(network_namespace(viewer_did), token) { + Some(_) -> #(Network, Some(token)) + None -> + case + pagination.decode_cursor(following_namespace(viewer_did), token) + { + Some(_) -> #(Following, Some(token)) + None -> #(Following, None) + } + } } - False -> #(feed_skeleton.collapse(followed_page), followed_cursor, False) } } +fn following_namespace(viewer_did: String) -> String { + following_namespace_prefix <> cursor_scope(viewer_did) +} + +fn network_namespace(viewer_did: String) -> String { + network_namespace_prefix <> cursor_scope(viewer_did) +} + +fn cursor_scope(viewer_did: String) -> String { + string.replace(viewer_did, ":", "-") +} + fn sort_desc(rows: List(Adoption)) -> List(Adoption) { list.sort(rows, fn(a, b) { string.compare(b.created_at, a.created_at) }) } diff --git a/server/src/crate_server/handlers/graph.gleam b/server/src/crate_server/handlers/graph.gleam index 9c9314d..1f6f5b8 100644 --- a/server/src/crate_server/handlers/graph.gleam +++ b/server/src/crate_server/handlers/graph.gleam @@ -3,8 +3,10 @@ //// the feed sees it without waiting for the firehose to echo it back. import atproto/uri -import atproto/xrpc.{type Client} +import atproto/xrpc.{type Client, type XrpcError} +import atproto_core/xrpc as core_xrpc import crate/gen/graph/follow.{type GraphFollow} +import crate/storage.{type StoredItem} import crate_server/context.{ type Context, error_json, require_session, with_pds_client, } @@ -13,7 +15,8 @@ import crate_server/graph_follows import crate_server/oauth/sessions.{type OauthSession} import gleam/dynamic/decode import gleam/json -import gleam/option.{type Option, None, Some} +import gleam/list +import gleam/option.{Some} import wisp.{type Request, type Response} fn subject_decoder() -> decode.Decoder(String) { @@ -37,17 +40,63 @@ fn do_follow( subject_did: String, ) -> Response { use client, session <- with_pds_client(ctx, id, session) + case stored_follows(ctx, client, session, subject_did) { + Error(error) -> pds_error(error, "could not load your follows from PDS") + Ok(uris) -> + case list.first(uris) { + Ok(uri) -> follow_response(uri) + Error(Nil) -> create_follow(ctx, client, session, subject_did) + } + } +} + +fn create_follow( + ctx: Context, + client: Client, + session: OauthSession, + subject_did: String, +) -> Response { case graph_follows.create(client, session, subject_did) { - Error(_) -> error_json(502, "could not write follow record to PDS") + Error(error) -> pds_error(error, "could not write follow record to PDS") Ok(#(created, record)) -> { index_follow(ctx, session, created.uri, record) - json.object([#("uri", json.string(created.uri))]) - |> json.to_string - |> wisp.json_response(200) + follow_response(created.uri) } } } +fn follow_response(uri: String) -> Response { + json.object([#("uri", json.string(uri))]) + |> json.to_string + |> wisp.json_response(200) +} + +/// Existing sessions may not have the graph.follow scope that current OAuth +/// requests ask for. Preserve the authorization signal so the browser can +/// offer an explicit reauthorization instead of hiding it as an upstream +/// outage. +fn pds_error(error: XrpcError, fallback: String) -> Response { + case error { + core_xrpc.BadStatus(status, Some(code), _, _) + if status == 403 + || code == "InsufficientScope" + || code == "insufficient_scope" + -> + error_json( + 403, + "your session needs permission to follow collectors; sign in again to approve it", + ) + core_xrpc.BadStatus(status, _, _, _) if status == 403 -> + error_json( + 403, + "your session needs permission to follow collectors; sign in again to approve it", + ) + core_xrpc.BadStatus(status, _, _, _) if status == 401 -> + error_json(401, "your session expired while writing the follow record") + _ -> error_json(502, fallback) + } +} + fn index_follow( ctx: Context, session: OauthSession, @@ -78,49 +127,77 @@ fn do_unfollow( subject_did: String, ) -> Response { use client, session <- with_pds_client(ctx, id, session) - case stored_follow(ctx, client, session, subject_did) { - Error(Nil) -> error_json(502, "could not load your follows from PDS") - Ok(None) -> error_json(404, "not following that subject") - Ok(Some(follow_uri)) -> delete_and_confirm(ctx, client, session, follow_uri) + case stored_follows(ctx, client, session, subject_did) { + Error(error) -> pds_error(error, "could not load your follows from PDS") + Ok([]) -> error_json(404, "not following that subject") + Ok(follow_uris) -> delete_and_confirm(ctx, client, session, follow_uris) } } // Index-first; a caller the index has never seen still falls back to their -// own repo, same exception the feed makes. -fn stored_follow( +// own repo, same exception the feed makes. The index lookup returns every +// matching URI so legacy duplicate records are removed as one relationship. +fn stored_follows( ctx: Context, client: Client, session: OauthSession, subject_did: String, -) -> Result(Option(String), Nil) { +) -> Result(List(String), XrpcError) { case ctx.follow_index.seen.has_seen(session.did) { True -> Ok( - ctx.follow_index.edges.find(session.did, subject_did) - |> option.map(fn(f) { f.follow_uri }), + ctx.follow_index.edges.find_all(session.did, subject_did) + |> list.map(fn(f) { f.follow_uri }), ) False -> case graph_follows.load(client, session) { - Error(_) -> Error(Nil) - Ok(stored) -> + Error(error) -> Error(error) + Ok(stored) -> { + index_stored_follows(ctx, session, stored) Ok( - graph_follows.find(stored, subject_did) - |> option.map(fn(s) { s.uri }), + graph_follows.find_all(stored, subject_did) + |> list.map(fn(s) { s.uri }), ) + } } } } +fn index_stored_follows( + ctx: Context, + session: OauthSession, + stored: List(StoredItem(GraphFollow)), +) -> Nil { + let follows = + stored + |> list.map(fn(row) { + follow_index.Follow( + follow_uri: row.uri, + did: session.did, + subject: row.value.subject, + created_at: row.value.created_at, + ) + }) + ctx.follow_index.edges.replace_for_did(session.did, follows) + ctx.follow_index.seen.mark_seen(session.did) +} + fn delete_and_confirm( ctx: Context, client: Client, session: OauthSession, - follow_uri: String, + follow_uris: List(String), ) -> Response { - case graph_follows.delete(client, session, uri.rkey(follow_uri)) { - Error(_) -> error_json(502, "could not delete follow record on PDS") - Ok(Nil) -> { - ctx.follow_index.edges.delete(follow_uri) + case + list.try_each(follow_uris, fn(follow_uri) { + graph_follows.delete(client, session, uri.rkey(follow_uri)) + }) + { + Error(error) -> pds_error(error, "could not delete follow record on PDS") + Ok(_) -> { + list.each(follow_uris, fn(follow_uri) { + ctx.follow_index.edges.delete(follow_uri) + }) wisp.json_response("{}", 200) } } diff --git a/server/src/crate_server/oauth/client_metadata.gleam b/server/src/crate_server/oauth/client_metadata.gleam index 2ed1c30..22ad0e3 100644 --- a/server/src/crate_server/oauth/client_metadata.gleam +++ b/server/src/crate_server/oauth/client_metadata.gleam @@ -6,6 +6,7 @@ import crate/gen/catalog/artist as catalog_artist import crate/gen/catalog/edit as catalog_edit import crate/gen/catalog/genre as catalog_genre import crate/gen/catalog/release as catalog_release +import crate/gen/graph/follow as graph_follow import crate/gen/shelf/entry as shelf_entry import gleam/json import gleam/string @@ -31,6 +32,8 @@ pub const scopes = [ <> catalog_genre.collection, "repo:" <> catalog_edit.collection, + "repo:" + <> graph_follow.collection, "blob:image/*", ] diff --git a/server/src/crate_server/router.gleam b/server/src/crate_server/router.gleam index c1277b8..5672d78 100644 --- a/server/src/crate_server/router.gleam +++ b/server/src/crate_server/router.gleam @@ -4,6 +4,7 @@ import crate_server/context.{type Context, error_json} import crate_server/handlers/amend import crate_server/handlers/browse +import crate_server/handlers/connections import crate_server/handlers/cover_proxy import crate_server/handlers/crate_overlap import crate_server/handlers/debug_index @@ -88,6 +89,7 @@ fn crate_xrpc( "shelf.getCrateOverlap", Get -> crate_overlap.get_crate_overlap(req, ctx) "graph.followUser", Post -> graph.follow(req, ctx) "graph.unfollowUser", Post -> graph.unfollow(req, ctx) + "graph.listConnections", Get -> connections.list_connections(req, ctx) "feed.getFeedSkeleton", Get -> feed.get_feed_skeleton(req, ctx) "discogs.searchReleases", Get -> discogs.search(req, ctx) "discogs.searchArtists", Get -> discogs.search_artists(req, ctx) diff --git a/server/src/crate_server/user_backfill.gleam b/server/src/crate_server/user_backfill.gleam index 93319ca..4cb4827 100644 --- a/server/src/crate_server/user_backfill.gleam +++ b/server/src/crate_server/user_backfill.gleam @@ -120,11 +120,17 @@ fn backfill_follows( created_at: follow.created_at, )) }) - rows |> list.each(index.edges.upsert) case complete { - True -> index.seen.mark_seen(user.did) + True -> { + index.edges.replace_for_did(user.did, rows) + index.seen.mark_seen(user.did) + } False -> Nil } + case complete { + True -> Nil + False -> rows |> list.each(index.edges.upsert) + } list.length(rows) } @@ -298,7 +304,24 @@ fn fetch_all_checked( decode_row: fn(RecordEntry) -> Result(a, Nil), ) -> #(List(a), Bool) { let #(records, complete) = fetch_pages(client, user, collection, None, [], 0) - #(list.filter_map(records, decode_row), complete) + let #(rows, decoded) = decode_rows_checked(records, decode_row, [], True) + #(rows, complete && decoded) +} + +fn decode_rows_checked( + records: List(RecordEntry), + decode_row: fn(RecordEntry) -> Result(a, Nil), + acc: List(a), + complete: Bool, +) -> #(List(a), Bool) { + case records { + [] -> #(list.reverse(acc), complete) + [record, ..rest] -> + case decode_row(record) { + Ok(row) -> decode_rows_checked(rest, decode_row, [row, ..acc], complete) + Error(_) -> decode_rows_checked(rest, decode_row, acc, False) + } + } } /// `fetch_all`'s undecoded form: the raw paged rows, so a caller needing two diff --git a/server/test/connections_handler_test.gleam b/server/test/connections_handler_test.gleam new file mode 100644 index 0000000..6a2d93f --- /dev/null +++ b/server/test/connections_handler_test.gleam @@ -0,0 +1,280 @@ +//// Handler-level coverage for the read-only connections contract. + +import atproto/xrpc +import atproto_core/xrpc as core_xrpc +import crate_server/context.{type Context, Context} +import crate_server/follow_index +import crate_server/handlers/connections +import crate_server/oauth/config +import crate_server/oauth/session_store +import crate_server/oauth/sessions +import gleam/bit_array +import gleam/dynamic/decode +import gleam/http +import gleam/http/response +import gleam/int +import gleam/json +import gleam/list +import gleam/result +import support +import wisp +import wisp/simulate + +const session_cookie = "ar_oauth_sid" + +fn auth_session() -> sessions.OauthSession { + let session = support.stub_session() + sessions.OauthSession(..session, expires_at: 9_999_999_999) +} + +fn authed_get( + path: String, + cfg: config.Config, + session: sessions.OauthSession, +) -> wisp.Request { + let assert Ok(id) = session_store.create(cfg.sessions, session) + simulate.request(http.Get, path) + |> simulate.cookie(session_cookie, id, wisp.Signed) +} + +fn context_with( + client: xrpc.Client, + follows: follow_index.Store, +) -> #(Context, config.Config) { + let cfg = support.stub_config(client) + let ctx = + Context( + ..support.stub_context_with( + cfg, + support.unreachable_catalog_deps(), + fn(_req) { Error("unused") }, + [], + ), + follow_index: follows, + ) + #(ctx, cfg) +} + +fn ok_response( + body: String, +) -> Result(response.Response(BitArray), xrpc.TransportError) { + Ok(response.Response(200, [], bit_array.from_string(body))) +} + +fn list_records_client(body: String) -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.listRecords" -> ok_response(body) + _ -> Error(core_xrpc.ConnectionFailed("unused")) + } + }) +} + +fn item_bools(body: String, field: String) -> Result(List(Bool), Nil) { + json.parse( + body, + decode.at( + ["items"], + decode.list(decode.field(field, decode.bool, decode.success)), + ), + ) + |> result.replace_error(Nil) +} + +fn follower_edge( + uri: String, + did: String, + subject: String, +) -> follow_index.Follow { + follow_index.Follow( + follow_uri: "at://" <> did <> "/dev.mokkenstorm.crate.graph.follow/" <> uri, + did:, + subject:, + created_at: "2026-01-01T00:00:00Z", + ) +} + +fn seed_followers( + store: follow_index.Store, + viewer: String, + dids: List(String), +) -> Nil { + dids + |> list.each(fn(did) { + store.edges.upsert(follower_edge("r" <> did, did, viewer)) + }) +} + +fn list_body(subjects: List(String)) -> String { + subjects + |> list.index_map(fn(subject, index) { + json.object([ + #( + "uri", + json.string( + "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/r" + <> int.to_string(index), + ), + ), + #("cid", json.string("cid" <> int.to_string(index))), + #( + "value", + json.object([ + #("subject", json.string(subject)), + #("createdAt", json.string("2026-01-01T00:00:00Z")), + ]), + ), + ]) + }) + |> json.preprocessed_array + |> fn(records) { json.object([#("records", records)]) } + |> json.to_string +} + +pub fn unauthenticated_connections_are_rejected_test() { + let #(ctx, _cfg) = + context_with(support.unreachable_client(), support.fresh_follow_index()) + let req = + simulate.request( + http.Get, + support.xrpc("graph.listConnections?direction=followers"), + ) + assert connections.list_connections(req, ctx).status == 401 +} + +pub fn followers_do_not_require_the_viewers_pds_test() { + let follows = support.fresh_follow_index() + seed_followers(follows, "did:plc:me", ["did:plc:follower"]) + follows.seen.mark_seen("did:plc:me") + let #(ctx, cfg) = context_with(support.unreachable_client(), follows) + let req = + authed_get( + support.xrpc("graph.listConnections?direction=followers"), + cfg, + auth_session(), + ) + let resp = connections.list_connections(req, ctx) + assert resp.status == 200 + let body = simulate.read_body(resp) + assert support.field_nested(body, ["items"], ["did"]) + == Ok(["did:plc:follower"]) + assert support.field_bool(body, ["relationshipComplete"]) == Ok(True) +} + +pub fn cold_followers_report_incomplete_relationships_test() { + let follows = support.fresh_follow_index() + seed_followers(follows, "did:plc:me", ["did:plc:follower"]) + let #(ctx, cfg) = context_with(support.unreachable_client(), follows) + let req = + authed_get( + support.xrpc("graph.listConnections?direction=followers"), + cfg, + auth_session(), + ) + let body = simulate.read_body(connections.list_connections(req, ctx)) + assert support.field_bool(body, ["relationshipComplete"]) == Ok(False) + assert item_bools(body, "viewerFollows") == Ok([False]) +} + +pub fn following_pds_failure_is_upstream_failure_test() { + let follows = support.fresh_follow_index() + follows.edges.upsert(follower_edge("stale", "did:plc:me", "did:plc:stale")) + follows.seen.mark_seen("did:plc:me") + let #(ctx, cfg) = context_with(support.unreachable_client(), follows) + let req = + authed_get( + support.xrpc("graph.listConnections?direction=following"), + cfg, + auth_session(), + ) + assert connections.list_connections(req, ctx).status == 502 + assert follows.edges.following("did:plc:me") == ["did:plc:stale"] +} + +pub fn identity_failure_falls_back_to_the_did_test() { + let follows = support.fresh_follow_index() + seed_followers(follows, "did:plc:me", ["did:plc:unresolvable"]) + follows.seen.mark_seen("did:plc:me") + let #(ctx, cfg) = context_with(support.unreachable_client(), follows) + let req = + authed_get( + support.xrpc("graph.listConnections?direction=followers"), + cfg, + auth_session(), + ) + let body = simulate.read_body(connections.list_connections(req, ctx)) + assert support.field_nested(body, ["items"], ["handle"]) + == Ok(["did:plc:unresolvable"]) +} + +pub fn following_uses_pds_data_and_marks_the_viewer_complete_test() { + let follows = support.fresh_follow_index() + follows.edges.upsert(follower_edge("stale", "did:plc:me", "did:plc:stale")) + let #(ctx, cfg) = + context_with( + list_records_client(list_body(["did:plc:first", "did:plc:second"])), + follows, + ) + let req = + authed_get( + support.xrpc("graph.listConnections?direction=following&limit=1"), + cfg, + auth_session(), + ) + let resp = connections.list_connections(req, ctx) + assert resp.status == 200 + let body = simulate.read_body(resp) + assert support.field_nested(body, ["items"], ["did"]) == Ok(["did:plc:first"]) + assert support.field_bool(body, ["complete"]) == Ok(True) + assert support.field_bool(body, ["relationshipComplete"]) == Ok(False) + assert support.field_present(body, ["cursor"]) + assert follows.edges.following("did:plc:me") + == [ + "did:plc:first", + "did:plc:second", + ] +} + +pub fn connection_cursor_paginates_and_is_viewer_scoped_test() { + let follows = support.fresh_follow_index() + seed_followers(follows, "did:plc:me", ["did:plc:a", "did:plc:b"]) + follows.seen.mark_seen("did:plc:me") + let #(ctx, cfg) = context_with(support.unreachable_client(), follows) + let first_req = + authed_get( + support.xrpc("graph.listConnections?direction=followers&limit=1"), + cfg, + auth_session(), + ) + let first_body = + simulate.read_body(connections.list_connections(first_req, ctx)) + let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) + let next_req = + authed_get( + support.xrpc( + "graph.listConnections?direction=followers&limit=1&cursor=" <> cursor, + ), + cfg, + auth_session(), + ) + let next_body = + simulate.read_body(connections.list_connections(next_req, ctx)) + assert support.field_nested(next_body, ["items"], ["did"]) + == Ok(["did:plc:b"]) + + let other = sessions.OauthSession(..auth_session(), did: "did:plc:other") + seed_followers(follows, "did:plc:other", ["did:plc:c"]) + follows.seen.mark_seen("did:plc:other") + let other_req = + authed_get( + support.xrpc( + "graph.listConnections?direction=followers&limit=1&cursor=" <> cursor, + ), + cfg, + other, + ) + let other_body = + simulate.read_body(connections.list_connections(other_req, ctx)) + assert support.field_nested(other_body, ["items"], ["did"]) + == Ok(["did:plc:c"]) +} diff --git a/server/test/feed_handler_test.gleam b/server/test/feed_handler_test.gleam index 211cc94..32662df 100644 --- a/server/test/feed_handler_test.gleam +++ b/server/test/feed_handler_test.gleam @@ -36,12 +36,14 @@ fn feed_path() -> String { support.xrpc("feed.getFeedSkeleton") } -fn a_session() -> sessions.OauthSession { - support.stub_session_with( - access_token: "at", - refresh_token: "rt", - expires_at: far_future, - ) +fn a_session_as(did: String) -> sessions.OauthSession { + let session = + support.stub_session_with( + access_token: "at", + refresh_token: "rt", + expires_at: far_future, + ) + sessions.OauthSession(..session, did:) } /// Always an "acquired" row -- no test in this file varies the status. @@ -126,7 +128,15 @@ fn test_context( } fn authed_get(path: String, cfg: config.Config) -> wisp.Request { - let assert Ok(id) = session_store.create(cfg.sessions, a_session()) + authed_get_as(path, cfg, viewer_did) +} + +fn authed_get_as( + path: String, + cfg: config.Config, + did: String, +) -> wisp.Request { + let assert Ok(id) = session_store.create(cfg.sessions, a_session_as(did)) simulate.request(http.Get, path) |> simulate.cookie(session_cookie, id, wisp.Signed) } @@ -174,12 +184,21 @@ pub fn followed_rows_are_included_and_viewer_and_non_followed_are_excluded_test( network_client( list_records_body([follow_record_json("3aaa", "did:plc:f1")]), ) - let #(ctx, cfg, _follows) = test_context(client, store) + let #(ctx, cfg, follows) = test_context(client, store) + follows.edges.upsert(follow_index.Follow( + follow_uri: "at://" + <> viewer_did + <> "/dev.mokkenstorm.crate.graph.follow/stale", + did: viewer_did, + subject: "did:plc:stale", + created_at: "2026-01-01T00:00:00Z", + )) let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) assert resp.status == 200 let body = simulate.read_body(resp) assert support.field_bool(body, ["fallback"]) == Ok(False) assert item_actors(body) == ["did:plc:f1"] + assert follows.edges.following(viewer_did) == ["did:plc:f1"] } pub fn empty_follow_graph_falls_back_to_network_wide_including_the_viewer_test() { @@ -228,6 +247,79 @@ pub fn nonempty_follows_with_no_followed_adoptions_falls_back_on_the_first_page_ assert item_actors(body) == ["did:plc:n1"] } +pub fn network_fallback_cursor_continues_in_network_mode_test() { + let assert Ok(store) = catalog_index.start() + store.adoptions.upsert(adoption( + did: "did:plc:n1", + rkey: "e1", + release_uri: release_uri("r1"), + created_at: nth_day(3), + )) + store.adoptions.upsert(adoption( + did: "did:plc:n2", + rkey: "e2", + release_uri: release_uri("r2"), + created_at: nth_day(2), + )) + let client = + network_client( + list_records_body([follow_record_json("3bbb", "did:plc:f1")]), + ) + let #(ctx, cfg, _follows) = test_context(client, store) + let first = + feed.get_feed_skeleton(authed_get(feed_path() <> "?limit=1", cfg), ctx) + let first_body = simulate.read_body(first) + assert support.field_bool(first_body, ["fallback"]) == Ok(True) + assert item_actors(first_body) == ["did:plc:n1"] + let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) + let second = + feed.get_feed_skeleton( + authed_get(feed_path() <> "?limit=1&cursor=" <> cursor, cfg), + ctx, + ) + let second_body = simulate.read_body(second) + assert support.field_bool(second_body, ["fallback"]) == Ok(True) + assert item_actors(second_body) == ["did:plc:n2"] + assert support.field_present(second_body, ["cursor"]) == False +} + +pub fn network_fallback_cursor_is_scoped_to_the_viewer_test() { + let assert Ok(store) = catalog_index.start() + store.adoptions.upsert(adoption( + did: "did:plc:n1", + rkey: "e1", + release_uri: release_uri("r1"), + created_at: nth_day(3), + )) + store.adoptions.upsert(adoption( + did: "did:plc:n2", + rkey: "e2", + release_uri: release_uri("r2"), + created_at: nth_day(2), + )) + let client = + network_client( + list_records_body([follow_record_json("3bbb", "did:plc:f1")]), + ) + let #(ctx, cfg, _follows) = test_context(client, store) + let first = + feed.get_feed_skeleton(authed_get(feed_path() <> "?limit=1", cfg), ctx) + let first_body = simulate.read_body(first) + let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) + let second = + feed.get_feed_skeleton( + authed_get_as( + feed_path() <> "?limit=1&cursor=" <> cursor, + cfg, + "did:plc:other", + ), + ctx, + ) + let second_body = simulate.read_body(second) + assert support.field_bool(second_body, ["fallback"]) == Ok(True) + assert item_actors(second_body) == ["did:plc:n1"] +} + pub fn cursor_stays_in_the_followed_namespace_across_pages_test() { let assert Ok(store) = catalog_index.start() store.adoptions.upsert(adoption( diff --git a/server/test/follow_index_postgres_test.gleam b/server/test/follow_index_postgres_test.gleam index fe9d16f..2d65897 100644 --- a/server/test/follow_index_postgres_test.gleam +++ b/server/test/follow_index_postgres_test.gleam @@ -42,6 +42,8 @@ pub fn follow_round_trip_test() { store.edges.upsert(follow(did, "did:plc:subj-b", "fr2")) assert list.sort(store.edges.following(did), string.compare) == ["did:plc:subj-a", "did:plc:subj-b"] + store.edges.upsert(follow("did:plc:other", did, "fr3")) + assert store.edges.followers(did) == ["did:plc:other"] let assert Some(found) = store.edges.find(did, "did:plc:subj-a") assert found.created_at == "2024-01-01T00:00:00Z" diff --git a/server/test/follow_index_test.gleam b/server/test/follow_index_test.gleam index d574e0c..191f67f 100644 --- a/server/test/follow_index_test.gleam +++ b/server/test/follow_index_test.gleam @@ -26,6 +26,23 @@ pub fn following_lists_only_that_dids_subjects_test() { assert store.edges.count() == 3 } +pub fn followers_returns_authors_in_deterministic_order_test() { + let assert Ok(store) = follow_index.start() + store.edges.upsert(follow("did:z", "did:a", "f1")) + store.edges.upsert(follow("did:b", "did:a", "f2")) + store.edges.upsert(follow("did:c", "did:other", "f3")) + assert store.edges.followers("did:a") == ["did:b", "did:z"] +} + +pub fn duplicate_records_are_preserved_until_their_uri_is_deleted_test() { + let assert Ok(store) = follow_index.start() + store.edges.upsert(follow("did:a", "did:b", "f1")) + store.edges.upsert(follow("did:a", "did:b", "f2")) + assert store.edges.following("did:a") == ["did:b", "did:b"] + store.edges.delete("at://did:a/dev.mokkenstorm.crate.graph.follow/f1") + assert store.edges.following("did:a") == ["did:b"] +} + pub fn following_is_empty_until_a_did_is_seen_test() { let assert Ok(store) = follow_index.start() assert store.seen.has_seen("did:a") == False @@ -51,6 +68,16 @@ pub fn deleting_a_follow_drops_only_that_edge_test() { assert store.edges.following("did:a") == ["did:c"] } +pub fn replacing_a_dids_edges_reconciles_stale_rows_atomically_test() { + let assert Ok(store) = follow_index.start() + store.edges.upsert(follow("did:a", "did:old", "f1")) + store.edges.upsert(follow("did:a", "did:keep", "f2")) + store.edges.upsert(follow("did:z", "did:old", "f3")) + store.edges.replace_for_did("did:a", [follow("did:a", "did:new", "f4")]) + assert store.edges.following("did:a") == ["did:new"] + assert store.edges.following("did:z") == ["did:old"] +} + pub fn delete_for_did_drops_that_dids_follows_and_seen_mark_test() { let assert Ok(store) = follow_index.start() store.edges.upsert(follow("did:a", "did:b", "f1")) diff --git a/server/test/graph_handler_test.gleam b/server/test/graph_handler_test.gleam index 291c0f8..6a132c7 100644 --- a/server/test/graph_handler_test.gleam +++ b/server/test/graph_handler_test.gleam @@ -2,6 +2,7 @@ //// plus the pure `graph_follows.find` lookup. import atproto/xrpc +import atproto_core/xrpc as core_xrpc import crate/gen/graph/follow.{type GraphFollow, GraphFollow} import crate/storage.{type StoredItem, StoredItem} import crate_server/context.{type Context, Context} @@ -12,10 +13,14 @@ import crate_server/oauth/config import crate_server/oauth/session_store import crate_server/oauth/sessions import gleam/bit_array +import gleam/erlang/process import gleam/http import gleam/http/response +import gleam/int import gleam/json +import gleam/list import gleam/option.{None, Some} +import gleam/string import support import wisp import wisp/simulate @@ -96,17 +101,34 @@ fn ok_response( Ok(response.Response(200, [], bit_array.from_string(body))) } +fn forbidden_response( + body: String, +) -> Result(response.Response(BitArray), xrpc.TransportError) { + Ok(response.Response(403, [], bit_array.from_string(body))) +} + fn network_client(list_body: String, create_body: String) -> xrpc.Client { xrpc.Client(send: fn(req) { case req.path { "/xrpc/com.atproto.repo.listRecords" -> ok_response(list_body) - "/xrpc/com.atproto.repo.createRecord" -> ok_response(create_body) + "/xrpc/com.atproto.repo.putRecord" -> ok_response(create_body) "/xrpc/com.atproto.repo.deleteRecord" -> ok_response("{}") _ -> panic as { "unexpected path: " <> req.path } } }) } +fn missing_scope_client() -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.listRecords" -> ok_response(list_records_body([])) + "/xrpc/com.atproto.repo.putRecord" -> + forbidden_response("{\"error\":\"InsufficientScope\"}") + _ -> panic as { "unexpected path: " <> req.path } + } + }) +} + fn test_context(client: xrpc.Client) -> #(Context, config.Config) { let cfg = support.stub_config(client) let ctx = @@ -149,6 +171,32 @@ pub fn follow_creates_a_record_and_returns_its_uri_test() { == Ok("at://did:plc:x/dev.mokkenstorm.crate.graph.follow/3ccc") } +pub fn follow_requires_authentication_test() { + let #(ctx, _cfg) = test_context(network_client(list_records_body([]), "")) + let req = + simulate.request(http.Post, support.xrpc("graph.followUser")) + |> simulate.json_body(json.object([#("subject", json.string(target_did))])) + assert graph.follow(req, ctx).status == 401 +} + +pub fn follow_scope_failure_is_exposed_as_a_reauthorization_signal_test() { + let #(ctx, cfg) = test_context(missing_scope_client()) + let req = + authed_post( + support.xrpc("graph.followUser"), + json.object([#("subject", json.string(target_did))]), + cfg, + ) + let resp = graph.follow(req, ctx) + assert resp.status == 403 + let body = simulate.read_body(resp) + assert support.field_string(body, ["error"]) == Ok("Forbidden") + assert support.field_string(body, ["message"]) + == Ok( + "your session needs permission to follow collectors; sign in again to approve it", + ) +} + pub fn unfollow_deletes_the_matching_follow_and_returns_ok_test() { let #(ctx, cfg) = test_context(network_client( @@ -206,15 +254,49 @@ fn no_list_client() -> xrpc.Client { xrpc.Client(send: fn(req) { case req.path { "/xrpc/com.atproto.repo.deleteRecord" -> ok_response("{}") - "/xrpc/com.atproto.repo.createRecord" -> + "/xrpc/com.atproto.repo.putRecord" -> ok_response(create_record_response("3ccc")) _ -> panic as { "unexpected PDS call: " <> req.path } } }) } +fn no_create_client(list_body: String) -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.listRecords" -> ok_response(list_body) + "/xrpc/com.atproto.repo.deleteRecord" -> ok_response("{}") + "/xrpc/com.atproto.repo.createRecord" -> + panic as "idempotent follow attempted a create" + _ -> panic as { "unexpected PDS call: " <> req.path } + } + }) +} + +pub fn follow_is_idempotent_and_chooses_a_stable_legacy_record_test() { + let #(ctx, cfg) = + test_context( + no_create_client( + list_records_body([ + follow_record_json("3bbb", target_did), + follow_record_json("3aaa", target_did), + ]), + ), + ) + let req = + authed_post( + support.xrpc("graph.followUser"), + json.object([#("subject", json.string(target_did))]), + cfg, + ) + let body = simulate.read_body(graph.follow(req, ctx)) + assert support.field_string(body, ["uri"]) + == Ok("at://did:plc:x/dev.mokkenstorm.crate.graph.follow/3aaa") +} + pub fn follow_mirrors_the_written_record_into_the_index_test() { let assert Ok(follows) = follow_index.start() + follows.seen.mark_seen(viewer_did) let #(ctx, cfg) = test_context_with(no_list_client(), follows) let req = authed_post( @@ -248,6 +330,32 @@ pub fn unfollow_finds_the_rkey_in_the_index_without_a_pds_list_test() { assert follows.edges.following(viewer_did) == [] } +pub fn unfollow_removes_all_legacy_duplicate_index_edges_test() { + let assert Ok(follows) = follow_index.start() + follows.edges.upsert(follow_index.Follow( + follow_uri: follow_uri("3aaa"), + did: viewer_did, + subject: target_did, + created_at: "2026-01-01T00:00:00Z", + )) + follows.edges.upsert(follow_index.Follow( + follow_uri: follow_uri("3bbb"), + did: viewer_did, + subject: target_did, + created_at: "2026-01-02T00:00:00Z", + )) + follows.seen.mark_seen(viewer_did) + let #(ctx, cfg) = test_context_with(no_list_client(), follows) + let req = + authed_post( + support.xrpc("graph.unfollowUser"), + json.object([#("subject", json.string(target_did))]), + cfg, + ) + assert graph.unfollow(req, ctx).status == 200 + assert follows.edges.following(viewer_did) == [] +} + pub fn unfollow_404s_from_the_index_when_the_edge_is_absent_test() { let assert Ok(follows) = follow_index.start() follows.seen.mark_seen(viewer_did) @@ -260,3 +368,60 @@ pub fn unfollow_404s_from_the_index_when_the_edge_is_absent_test() { ) assert graph.unfollow(req, ctx).status == 404 } + +pub fn deterministic_rkey_is_stable_and_atproto_safe_test() { + let first = graph_follows.rkey_for_subject(target_did) + let second = graph_follows.rkey_for_subject(target_did) + assert first == second + assert string.starts_with(first, "f_") + assert string.contains(first, ":") == False +} + +pub fn concurrent_follows_converge_on_the_same_put_rkey_test() { + let expected_rkey = graph_follows.rkey_for_subject(target_did) + let client = + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.listRecords" -> + ok_response(list_records_body([])) + "/xrpc/com.atproto.repo.putRecord" -> { + case bit_array.to_string(req.body) { + Ok(body) -> + case string.contains(body, expected_rkey) { + True -> ok_response(create_record_response(expected_rkey)) + False -> + Error(core_xrpc.ConnectionFailed( + "non-deterministic follow rkey", + )) + } + _ -> + Error(core_xrpc.ConnectionFailed("non-deterministic follow rkey")) + } + } + _ -> panic as { "unexpected PDS call: " <> req.path } + } + }) + let #(ctx, cfg) = test_context(client) + let first = + authed_post( + support.xrpc("graph.followUser"), + json.object([#("subject", json.string(target_did))]), + cfg, + ) + let second = + authed_post( + support.xrpc("graph.followUser"), + json.object([#("subject", json.string(target_did))]), + cfg, + ) + let replies = process.new_subject() + process.spawn_unlinked(fn() { + process.send(replies, graph.follow(first, ctx).status) + }) + process.spawn_unlinked(fn() { + process.send(replies, graph.follow(second, ctx).status) + }) + let assert Ok(a) = process.receive(replies, 1000) + let assert Ok(b) = process.receive(replies, 1000) + assert list.sort([a, b], int.compare) == [200, 200] +} diff --git a/server/test/oauth_test.gleam b/server/test/oauth_test.gleam index 4d34659..fc0dcfd 100644 --- a/server/test/oauth_test.gleam +++ b/server/test/oauth_test.gleam @@ -1,9 +1,11 @@ import atproto/xrpc +import crate/gen/graph/follow as graph_follow import crate_server/catalog_index import crate_server/context.{type Context, Context} import crate_server/handlers/oauth as oauth_handler import crate_server/oauth/assertion import crate_server/oauth/authed +import crate_server/oauth/client_metadata import crate_server/oauth/config import crate_server/oauth/dpop import crate_server/oauth/flow @@ -20,6 +22,7 @@ import gleam/erlang/process import gleam/http import gleam/http/request import gleam/http/response +import gleam/json import gleam/list import gleam/option.{None, Some} import gleam/result @@ -132,6 +135,15 @@ pub fn config_https_is_confidential_client_test() { assert cfg.redirect_uri == "https://app.example/api/oauth/callback" } +pub fn follow_scope_is_declared_in_client_metadata_test() { + let follow_scope = "repo:" <> graph_follow.collection + let scope = client_metadata.scope() + let body = client_metadata.document("https://app.example") |> json.to_string + + assert string.contains(scope, follow_scope) + assert support.field_string(body, ["scope"]) == Ok(scope) +} + // -- SSRF guard on the login path ----------------------------------------- // // Both the resolved PDS and the PAR endpoint the PDS's own discovery diff --git a/server/test/user_backfill_test.gleam b/server/test/user_backfill_test.gleam index c12c6eb..042f7bb 100644 --- a/server/test/user_backfill_test.gleam +++ b/server/test/user_backfill_test.gleam @@ -11,6 +11,7 @@ import crate/gen/catalog/release as catalog_release import crate/gen/graph/follow as graph_follow import crate/gen/shelf/entry as shelf_entry import crate_server/catalog_index +import crate_server/follow_index import crate_server/known_users.{KnownUser} import crate_server/shelf_index import crate_server/user_backfill @@ -395,6 +396,14 @@ fn follow_record(rkey: String, subject: String) -> json.Json { ) } +fn undecodable_follow_record(rkey: String) -> json.Json { + record( + "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/" <> rkey, + "bafy" <> rkey, + json.object([#("createdAt", json.string("2026-02-01T00:00:00Z"))]), + ) +} + fn follows_client(pages: fn(String) -> String) -> xrpc.Client { xrpc.Client(send: fn(req) { case collection_of(req) { @@ -409,6 +418,12 @@ pub fn backfill_seeds_follows_and_marks_the_did_seen_test() { let assert Ok(store) = catalog_index.start() let assert Ok(shelf) = shelf_index.start() let follows = support.fresh_follow_index() + follows.edges.upsert(follow_index.Follow( + follow_uri: "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/stale", + did: "did:plc:me", + subject: "did:plc:stale", + created_at: "2026-01-01T00:00:00Z", + )) let client = follows_client(fn(_q) { page_body([follow_record("f1", "did:plc:b")], None) @@ -424,6 +439,12 @@ pub fn backfill_marks_seen_even_with_an_empty_follow_graph_test() { let assert Ok(store) = catalog_index.start() let assert Ok(shelf) = shelf_index.start() let follows = support.fresh_follow_index() + follows.edges.upsert(follow_index.Follow( + follow_uri: "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/stale", + did: "did:plc:me", + subject: "did:plc:stale", + created_at: "2026-01-01T00:00:00Z", + )) let summary = user_backfill.backfill_user( follows_client(fn(_q) { empty_page_body() }), @@ -433,13 +454,51 @@ pub fn backfill_marks_seen_even_with_an_empty_follow_graph_test() { follows, ) assert summary.follows == 0 + assert follows.edges.following("did:plc:me") == [] assert follows.seen.has_seen("did:plc:me") == True } +pub fn malformed_follow_records_do_not_prune_edges_or_mark_seen_test() { + let assert Ok(store) = catalog_index.start() + let assert Ok(shelf) = shelf_index.start() + let follows = support.fresh_follow_index() + follows.edges.upsert(follow_index.Follow( + follow_uri: "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/stale", + did: "did:plc:me", + subject: "did:plc:stale", + created_at: "2026-01-01T00:00:00Z", + )) + let client = + follows_client(fn(_q) { + page_body( + [ + follow_record("f1", "did:plc:b"), + undecodable_follow_record("bad"), + ], + None, + ) + }) + let summary = + user_backfill.backfill_user(client, a_user(), store, shelf, follows) + assert summary.follows == 1 + assert follows.edges.following("did:plc:me") + == [ + "did:plc:b", + "did:plc:stale", + ] + assert follows.seen.has_seen("did:plc:me") == False +} + pub fn a_truncated_follow_run_keeps_rows_but_does_not_mark_seen_test() { let assert Ok(store) = catalog_index.start() let assert Ok(shelf) = shelf_index.start() let follows = support.fresh_follow_index() + follows.edges.upsert(follow_index.Follow( + follow_uri: "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/stale", + did: "did:plc:me", + subject: "did:plc:stale", + created_at: "2026-01-01T00:00:00Z", + )) // First page has a cursor; the follow-up request fails, so the run is // partial and must not claim the graph is fully indexed. let client = @@ -458,6 +517,10 @@ pub fn a_truncated_follow_run_keeps_rows_but_does_not_mark_seen_test() { let summary = user_backfill.backfill_user(client, a_user(), store, shelf, follows) assert summary.follows == 1 - assert follows.edges.following("did:plc:me") == ["did:plc:b"] + assert follows.edges.following("did:plc:me") + == [ + "did:plc:b", + "did:plc:stale", + ] assert follows.seen.has_seen("did:plc:me") == False } diff --git a/web/css/15-misc.css b/web/css/15-misc.css index fc8d86c..d530020 100644 --- a/web/css/15-misc.css +++ b/web/css/15-misc.css @@ -49,6 +49,16 @@ flex: 1; min-width: 0; } +.notice__action { + flex-shrink: 0; + width: auto; + border: 1px solid currentColor; + background: transparent; + color: inherit; + cursor: pointer; + font: 700 11px/1 var(--mono); + padding: 6px 8px; +} .notice__dismiss { flex-shrink: 0; width: auto; @@ -88,4 +98,3 @@ font-size: 13px; color: var(--ink-muted); } - diff --git a/web/css/21-connections.css b/web/css/21-connections.css new file mode 100644 index 0000000..75aa9d0 --- /dev/null +++ b/web/css/21-connections.css @@ -0,0 +1,81 @@ +/* --- connections ------------------------------------------------------ */ + +.connections { + gap: 0; +} + +.connections-tabs { + display: flex; + gap: 22px; + padding: 18px 20px 0; + border-bottom: 1px solid var(--line); +} + +.connections-tab { + padding: 0 0 12px; + color: var(--ink-muted); + font: 700 11px/1.2 var(--mono); + letter-spacing: 0.7px; + text-decoration: none; + border-bottom: 3px solid transparent; +} + +.connections-tab.is-active { + color: var(--ink); + border-bottom-color: var(--mustard); +} + +.connections-list { + padding: 0 20px 24px; +} + +.connections-note { + margin: 16px 0 4px; + color: var(--ink-muted); + font: 400 11px/1.45 var(--mono); +} + +.connections-rows { + margin-top: 8px; +} + +.connection-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 15px 0; + border-bottom: 1px solid var(--line); +} + +.connection-row__identity { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.connection-row__handle { + min-width: 0; + overflow: hidden; + color: var(--ink); + font: 700 13px/1.3 var(--mono); + text-overflow: ellipsis; + text-decoration: none; +} + +.connection-row__follow { + flex-shrink: 0; +} + +.connections-more { + display: block; + margin: 18px auto 0; +} + +.connections-error { + display: grid; + justify-items: center; + gap: 12px; + padding: 28px 20px; +} diff --git a/web/src/crate_web.gleam b/web/src/crate_web.gleam index dee89f5..c62be99 100644 --- a/web/src/crate_web.gleam +++ b/web/src/crate_web.gleam @@ -69,6 +69,12 @@ fn init(_flags) -> #(Model, Effect(Msg)) { overlap: None, pressing: model.PressingLoading, feed: model.FeedLoading, + feed_generation: 0, + connections: model.ConnectionsLoading, + connections_generation: 0, + connection_write_generation: 0, + connection_pending: None, + connections_loading_more: False, feed_entries: dict.new(), feed_loading_more: False, nav_depth: 0, @@ -81,7 +87,12 @@ fn init(_flags) -> #(Model, Effect(Msg)) { let route_effects = case active_route { model.Add -> [effects.discogs_status()] model.Browse -> [effects.load_browse()] - model.Feed -> [effects.load_feed(None)] + model.Feed -> [ + effects.load_feed(model.FeedRequest(model.Feed, 0, None)), + ] + model.ConnectionsFollowing | model.ConnectionsFollowers -> [ + effects.load_connections(model.ConnectionsRequest(active_route, 0, None)), + ] model.EditInbox -> [effects.load_edit_inbox()] model.PublicCrate(handle) -> [effects.load_shelf(Some(handle), "current")] model.PublicRecord(handle, entry_id) -> [ diff --git a/web/src/crate_web/effects.gleam b/web/src/crate_web/effects.gleam index f8c8e2f..e29e30f 100644 --- a/web/src/crate_web/effects.gleam +++ b/web/src/crate_web/effects.gleam @@ -12,20 +12,23 @@ import crate/gen/shelf/list_entries import crate_web/appview import crate_web/browser import crate_web/model.{ - type AmendDraft, type Display, type Form, type ReleaseInfo, type Theme, + type AmendDraft, type ConnectionWrite, type ConnectionsRequest, type Display, + type FeedRequest, type Form, type ReleaseInfo, type Theme, Connection, DiscogsSearchPage, HandleSuggestion, ReleaseInfo, } import crate_web/money import crate_web/msg.{ - type ApiError, type EntryDetailData, type FeedSkeletonData, type Msg, - type ShelfData, AppliedProposal, BarcodeDetected, CameraUnsupported, - CoverUploaded, CrateOverlapData, EntryDetailData, FeedSkeletonData, GotAction, - GotActorShelf, GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, - GotBrowse, GotBrowseAdd, GotBrowseMore, GotBrowseSearch, GotCrateOverlap, - GotDiscogs, GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, - GotEditInbox, GotFeedMore, GotFeedSkeleton, GotFollow, GotHandleSuggestions, - GotLogout, GotPressing, GotScanResult, GotScanSeen, GotShelf, GotShelfMore, - GotUnfollow, LinkCopied, ScanLookup, ShelfData, + type ApiError, type ConnectionsData, type EntryDetailData, + type FeedSkeletonData, type Msg, type ShelfData, AppliedProposal, + BarcodeDetected, CameraUnsupported, ConnectionsData, CoverUploaded, + CrateOverlapData, EntryDetailData, FeedSkeletonData, GotAction, GotActorShelf, + GotAdd, GotAmend, GotApplyProposal, GotArtists, GotAvatar, GotBrowse, + GotBrowseAdd, GotBrowseMore, GotBrowseSearch, GotConnectionFollow, + GotConnectionUnfollow, GotConnections, GotCrateOverlap, GotDiscogs, + GotDiscogsDisconnect, GotDiscogsImport, GotDiscogsStatus, GotEditInbox, + GotFeedMore, GotFeedSkeleton, GotFollow, GotHandleSuggestions, GotLogout, + GotPressing, GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotUnfollow, + LinkCopied, ScanLookup, ShelfData, } import crate_web/prefs import gleam/dict @@ -743,20 +746,19 @@ fn release_info_decoder() -> decode.Decoder(ReleaseInfo) { }) } -/// One page of the network activity feed. Dispatches `GotFeedSkeleton` for a -/// fresh load (`cursor` is `None`) and `GotFeedMore` for an infinite-scroll -/// page (`cursor` is `Some`); the query shape is identical either way. -pub fn load_feed(cursor: Option(String)) -> Effect(Msg) { - let to_msg = case cursor { - None -> GotFeedSkeleton - Some(_) -> GotFeedMore +/// One page of the network activity feed. The request identity is carried +/// through the async completion so stale route visits or cursors are ignored. +pub fn load_feed(request: FeedRequest) -> Effect(Msg) { + let to_msg = case request.cursor { + None -> fn(result) { GotFeedSkeleton(request, result) } + Some(_) -> fn(result) { GotFeedMore(request, result) } } let url = xrpc( "feed.getFeedSkeleton", list.flatten([ [#("limit", int.to_string(model.feed_page_limit))], - opt_param("cursor", cursor), + opt_param("cursor", request.cursor), ]), ) bff_get(url, feed_skeleton_decoder(), to_msg) @@ -776,6 +778,78 @@ fn feed_skeleton_decoder() -> decode.Decoder(FeedSkeletonData) { decode.success(FeedSkeletonData(items:, fallback:, cursor:)) } +pub fn load_connections(request: ConnectionsRequest) -> Effect(Msg) { + let direction = case request.route { + model.ConnectionsFollowers -> "followers" + _ -> "following" + } + bff_get( + xrpc( + "graph.listConnections", + list.flatten([ + [#("direction", direction), #("limit", "50")], + opt_param("cursor", request.cursor), + ]), + ), + connections_decoder(), + fn(result) { GotConnections(request, result) }, + ) +} + +fn connections_decoder() -> decode.Decoder(ConnectionsData) { + let item_decoder = { + use did <- decode.field("did", decode.string) + use handle <- decode.field("handle", decode.string) + use viewer_follows <- decode.field("viewerFollows", decode.bool) + use follows_viewer <- decode.field("followsViewer", decode.bool) + use mutual <- decode.field("mutual", decode.bool) + decode.success(Connection( + did:, + handle:, + viewer_follows:, + follows_viewer:, + mutual:, + )) + } + use items <- decode.field("items", decode.list(item_decoder)) + use cursor <- decode.optional_field( + "cursor", + None, + decode.optional(decode.string), + ) + use complete <- decode.optional_field("complete", False, decode.bool) + use relationship_complete <- decode.optional_field( + "relationshipComplete", + False, + decode.bool, + ) + decode.success(ConnectionsData( + items:, + cursor:, + complete:, + relationship_complete:, + )) +} + +pub fn follow_connection(request: ConnectionWrite) -> Effect(Msg) { + let decoder = decode.field("uri", decode.string, decode.success) + bff_post( + xrpc("graph.followUser", []), + json.object([#("subject", json.string(request.did))]), + decoder, + fn(result) { GotConnectionFollow(request, result) }, + ) +} + +pub fn unfollow_connection(request: ConnectionWrite) -> Effect(Msg) { + bff_post( + xrpc("graph.unfollowUser", []), + json.object([#("subject", json.string(request.did))]), + nil_decoder(), + fn(result) { GotConnectionUnfollow(request, result) }, + ) +} + pub fn load_crate_overlap(handle: String) -> Effect(Msg) { let decoder = { use common_count <- decode.field("commonCount", decode.int) diff --git a/web/src/crate_web/model.gleam b/web/src/crate_web/model.gleam index 7df02b1..ca5c899 100644 --- a/web/src/crate_web/model.gleam +++ b/web/src/crate_web/model.gleam @@ -38,6 +38,8 @@ pub type Route { RecordAmend(entry_id: String) Browse Feed + ConnectionsFollowing + ConnectionsFollowers EditInbox /// One proposal's detail screen, off `EditInbox`. EditProposalDetail(id: String) @@ -50,6 +52,41 @@ pub type Route { PressingDetail(did: String, rkey: String) } +pub type Connection { + Connection( + did: String, + handle: String, + viewer_follows: Bool, + follows_viewer: Bool, + mutual: Bool, + ) +} + +pub type ConnectionsState { + ConnectionsLoading + ConnectionsLoaded( + items: List(Connection), + cursor: Option(String), + complete: Bool, + relationship_complete: Bool, + ) + ConnectionsFailed +} + +/// Identity of one connections list request. The generation is bumped for +/// every reload or page fetch, so a response from an earlier request cannot +/// replace a newer copy of the same route. +pub type ConnectionsRequest { + ConnectionsRequest(route: Route, generation: Int, cursor: Option(String)) +} + +/// Identity of one optimistic follow write. Keeping the route and generation +/// with the DID prevents a completion from an earlier visit from changing the +/// current visit's row. +pub type ConnectionWrite { + ConnectionWrite(route: Route, generation: Int, did: String, desired: Bool) +} + /// How many crate items render before the LOAD MORE button, and the amount /// each LOAD MORE reveals. Purely a render cap: the server may have fetched /// more than this already (see `crate_page_limit`), in which case pressing @@ -252,6 +289,13 @@ pub type FeedState { FeedFailed } +/// Identity of one feed skeleton or pagination request. The generation is +/// bumped when the feed route changes and when a page fetch starts, so an +/// earlier response cannot replace or append to the current feed. +pub type FeedRequest { + FeedRequest(route: Route, generation: Int, cursor: Option(String)) +} + /// The day-separator label for a feed item: the newest hydrated entry's /// `updatedAt` day (ISO dates sort lexicographically), or `None` while none /// of its refs are hydrated yet. @@ -316,6 +360,10 @@ pub type NoticeLevel { pub type Notice { Notice(level: NoticeLevel, message: String) + /// A permission failure with an explicit action. This is intentionally + /// distinct from a generic warning so it cannot silently reauthorize an + /// existing OAuth session. + ReauthorizeNotice(message: String) } pub type Form { @@ -774,6 +822,16 @@ pub type Model { pressing: PressingState, // The network feed's load state, for the /feed route. feed: FeedState, + // Monotonic identity for feed skeleton and pagination requests. + feed_generation: Int, + // The signed-in viewer's following/followers list. + connections: ConnectionsState, + // Monotonic identities for list requests and optimistic writes. + connections_generation: Int, + connection_write_generation: Int, + // The follow/unfollow write currently in flight, if any. + connection_pending: Option(ConnectionWrite), + connections_loading_more: Bool, // Hydrated feed entry refs, keyed #(actor_did, entry_id). feed_entries: dict.Dict(#(String, String), EntryDetail), // True only while a cursor-driven feed page fetch is in flight. diff --git a/web/src/crate_web/msg.gleam b/web/src/crate_web/msg.gleam index 185999c..39ddb67 100644 --- a/web/src/crate_web/msg.gleam +++ b/web/src/crate_web/msg.gleam @@ -2,10 +2,11 @@ import atproto_core/xrpc import crate/gen/feed/get_feed_skeleton.{type FeedItem} import crate/gen/shelf/entry.{type ShelfEntry} import crate_web/model.{ - type ArtistHit, type BrowseRelease, type DiscogsResult, type DiscogsSearchPage, - type Display, type EditProposal, type Entry, type HandleSuggestion, - type ImportRun, type NetworkMatch, type ReleaseInfo, type Route, type ScanMode, - type Suggestion, type Theme, + type ArtistHit, type BrowseRelease, type Connection, type ConnectionWrite, + type ConnectionsRequest, type DiscogsResult, type DiscogsSearchPage, + type Display, type EditProposal, type Entry, type FeedRequest, + type HandleSuggestion, type ImportRun, type NetworkMatch, type ReleaseInfo, + type Route, type ScanMode, type Suggestion, type Theme, } import crate_web/photo_scan.{type DecodeError} import gleam/dict @@ -59,6 +60,15 @@ pub type FeedSkeletonData { ) } +pub type ConnectionsData { + ConnectionsData( + items: List(Connection), + cursor: Option(String), + complete: Bool, + relationship_complete: Bool, + ) +} + /// One page from `catalog.listReleases`, plus the opaque cursor for the next /// page when the filtered result set has more rows. pub type BrowseData { @@ -99,6 +109,7 @@ pub type Msg { GotHandleSuggestions(Result(List(HandleSuggestion), ApiError)) UseHandleSuggestion(HandleSuggestion) StartLogin + Reauthorize ArmLogout DisarmLogout Logout @@ -196,14 +207,29 @@ pub type Msg { GotApplyProposal(uri: String, result: Result(AppliedProposal, ApiError)) IgnoreProposal(uri: String) GotActorShelf(Result(ShelfData, ApiError)) - GotFeedSkeleton(Result(FeedSkeletonData, ApiError)) + GotFeedSkeleton( + request: FeedRequest, + result: Result(FeedSkeletonData, ApiError), + ) GotFeedEntry( actor: String, entry_id: String, result: Result(EntryDetailData, ApiError), ) FeedShowMore - GotFeedMore(Result(FeedSkeletonData, ApiError)) + GotFeedMore(request: FeedRequest, result: Result(FeedSkeletonData, ApiError)) + GotConnections( + request: ConnectionsRequest, + result: Result(ConnectionsData, ApiError), + ) + ConnectionsShowMore + RetryConnections + ToggleConnectionFollow(did: String, currently_following: Bool) + GotConnectionFollow( + request: ConnectionWrite, + result: Result(String, ApiError), + ) + GotConnectionUnfollow(request: ConnectionWrite, result: Result(Nil, ApiError)) GotCrateOverlap(Result(CrateOverlapData, ApiError)) /// Optimistically flips the public-crate page's follow state and fires /// the matching write; the target is the currently-open public crate's did. diff --git a/web/src/crate_web/pages/connections.gleam b/web/src/crate_web/pages/connections.gleam new file mode 100644 index 0000000..f02e758 --- /dev/null +++ b/web/src/crate_web/pages/connections.gleam @@ -0,0 +1,172 @@ +//// Following and followers for the signed-in viewer. Followers are an +//// appview index and may be incomplete, so an empty indexed result is never +//// presented as proof that nobody follows the viewer. + +import crate_web/model.{ + type Connection, type Model, ConnectionsFailed, ConnectionsLoaded, + ConnectionsLoading, +} +import crate_web/msg.{ + type Msg, ConnectionsShowMore, RetryConnections, ToggleConnectionFollow, +} +import crate_web/route +import crate_web/ui/controls as ctl +import crate_web/ui/states +import gleam/list +import gleam/option.{type Option, None, Some} +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +pub fn view(model: Model, followers: Bool) -> Element(Msg) { + html.div([attr.class("body page-scroll connections")], [ + tabs(followers), + case model.connections { + ConnectionsLoading -> loading() + ConnectionsFailed -> failed() + ConnectionsLoaded(items, cursor, complete, relationship_complete) -> + loaded(model, followers, items, cursor, complete, relationship_complete) + }, + ]) +} + +fn tabs(followers: Bool) -> Element(Msg) { + html.nav([attr.class("connections-tabs"), attr.aria_label("Connections")], [ + tab("FOLLOWING", route.to_path(model.ConnectionsFollowing), !followers), + tab("FOLLOWERS", route.to_path(model.ConnectionsFollowers), followers), + ]) +} + +fn tab(label: String, href: String, active: Bool) -> Element(Msg) { + html.a( + [ + attr.class(case active { + True -> "connections-tab is-active" + False -> "connections-tab" + }), + attr.href(href), + attr.attribute("aria-current", case active { + True -> "page" + False -> "false" + }), + ], + [text(label)], + ) +} + +fn loading() -> Element(Msg) { + html.p([attr.class("loading-status")], [text("◌ LOADING CONNECTIONS…")]) +} + +fn failed() -> Element(Msg) { + html.div([attr.class("connections-error")], [ + states.error_sticker("Couldn't load your connections right now."), + ctl.button("RETRY", ctl.Ghost, [event.on_click(RetryConnections)]), + ]) +} + +fn loaded( + model: Model, + followers: Bool, + items: List(Connection), + cursor: Option(String), + complete: Bool, + relationship_complete: Bool, +) -> Element(Msg) { + html.div([attr.class("connections-list")], [ + completeness_note(followers, items, complete), + case items { + [] -> empty(followers, complete) + _ -> + html.div( + [attr.class("connections-rows")], + list.map(items, row(model, relationship_complete, _)), + ) + }, + case cursor { + Some(_) -> + ctl.button("LOAD MORE", ctl.Ghost, [ + attr.class("connections-more"), + attr.disabled(model.connections_loading_more), + event.on_click(ConnectionsShowMore), + ]) + None -> element.none() + }, + ]) +} + +fn completeness_note( + followers: Bool, + items: List(Connection), + complete: Bool, +) -> Element(Msg) { + case followers && !complete { + True -> + html.p([attr.class("connections-note"), attr.role("status")], [ + text(case list.is_empty(items) { + True -> + "No indexed followers yet. The appview may still be catching up." + False -> "Followers may be incomplete while the appview catches up." + }), + ]) + False -> element.none() + } +} + +fn empty(followers: Bool, complete: Bool) -> Element(Msg) { + html.p([attr.class("empty")], [ + text(case followers { + True if !complete -> "No indexed followers yet." + True -> "No one follows you yet." + False -> "You are not following anyone yet." + }), + ]) +} + +fn row( + model: Model, + relationship_complete: Bool, + item: Connection, +) -> Element(Msg) { + let pending = case model.connection_pending { + Some(request) -> request.did == item.did && request.route == model.route + None -> False + } + html.div([attr.class("connection-row")], [ + html.div([attr.class("connection-row__identity")], [ + html.a( + [ + attr.class("connection-row__handle"), + attr.href(route.to_path(model.PublicCrate(item.handle))), + ], + [ + text("@" <> item.handle), + ], + ), + case relationship_complete && item.mutual { + True -> ctl.chip("MUTUAL", ctl.Accent) + False -> element.none() + }, + ]), + ctl.button( + case item.viewer_follows { + True -> "FOLLOWING" + False -> "FOLLOW" + }, + case item.viewer_follows { + True -> ctl.Ghost + False -> ctl.Primary + }, + [ + attr.class("connection-row__follow"), + attr.aria_label(case item.viewer_follows { + True -> "Unfollow @" <> item.handle + False -> "Follow @" <> item.handle + }), + attr.disabled(pending), + event.on_click(ToggleConnectionFollow(item.did, item.viewer_follows)), + ], + ), + ]) +} diff --git a/web/src/crate_web/pages/feed.gleam b/web/src/crate_web/pages/feed.gleam index 7e0cd52..b47dd47 100644 --- a/web/src/crate_web/pages/feed.gleam +++ b/web/src/crate_web/pages/feed.gleam @@ -23,6 +23,7 @@ import crate_web/ui/covers as cov import crate_web/ui/infinite_scroll as scroll import crate_web/ui/states import gleam/dict +import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import lustre/attribute as attr @@ -134,16 +135,22 @@ fn single_row( } } -/// `batch_row`/`import_row`'s shared scaffold: a skeleton until the first -/// entry hydrates, else a strip row labeled by `label_for` off that entry. +/// `batch_row`/`import_row`'s shared scaffold: a skeleton until an entry +/// hydrates, else a strip row that counts only known entries. An entry that +/// hydrates out of order must not make its actor look responsible for the +/// entire unresolved batch. fn hydrated_strip_row( model: Model, item: FeedItem, - label_for: fn(EntryDetail) -> String, + label_for: fn(List(#(EntryRef, EntryDetail)), Int) -> String, ) -> Element(Msg) { - case hydrated_entries(model, item) { + let hydrated = hydrated_entries(model, item) + case hydrated { [] -> skeleton_row() - [#(_, fe), ..] -> strip_row(model, fe.handle, label_for(fe), item) + [#(_, fe), ..] -> { + let unresolved = list.length(item.entries) - list.length(hydrated) + strip_row(model, fe.handle, label_for(hydrated, unresolved), item) + } } } @@ -152,13 +159,26 @@ fn batch_row( item: FeedItem, reason: ReasonActorBatch, ) -> Element(Msg) { - hydrated_strip_row(model, item, fn(fe) { - "@" - <> fe.handle - <> " " - <> reason.action - <> " " - <> plural.count_noun(list.length(item.entries), "record") + hydrated_strip_row(model, item, fn(hydrated, unresolved) { + let known = list.length(hydrated) + let actors = hydrated_handles(hydrated) + let label = case unresolved { + 0 -> + format_handles(actors) + <> " " + <> reason.action + <> " " + <> plural.count_noun(known, "record") + _ -> + format_handles(actors) + <> " " + <> reason.action + <> " " + <> plural.count_noun(known, "record") + <> ", plus " + <> plural.count_noun(unresolved, "unresolved record") + } + label }) } @@ -167,16 +187,24 @@ fn import_row( item: FeedItem, reason: ReasonImport, ) -> Element(Msg) { - hydrated_strip_row(model, item, fn(fe) { + hydrated_strip_row(model, item, fn(hydrated, unresolved) { let via = case reason.source { Some(src) -> " via " <> provenance.provider_label(src) None -> "" } - "@" - <> fe.handle - <> " imported " - <> plural.count_noun(list.length(item.entries), "record") - <> via + let label = case unresolved { + 0 -> + format_handles(hydrated_handles(hydrated)) + <> " imported " + <> plural.count_noun(list.length(hydrated), "record") + _ -> + format_handles(hydrated_handles(hydrated)) + <> " imported " + <> plural.count_noun(list.length(hydrated), "record") + <> ", plus " + <> plural.count_noun(unresolved, "unresolved record") + } + label <> via }) } @@ -219,22 +247,20 @@ fn converge_row( item: FeedItem, reason: ReasonSubjectConverge, ) -> Element(Msg) { - case first_ref_hydrated(model, item) { - None -> skeleton_row() - Some(#(ref, fe)) -> { + case hydrated_entries(model, item) { + [] -> skeleton_row() + [#(ref, fe), ..] -> { let snap = model.entry_snapshot(fe.entry) let href = case model.split_release_uri(reason.subject.uri) { Ok(#(did, rkey)) -> route.to_path(PressingDetail(did, rkey)) Error(_) -> route.to_path(PublicRecord(fe.handle, ref.entry_id)) } + let label = converge_actors(model, item) <> " picked up the same pressing" html.div([attr.class("feed-row")], [ html.div([attr.class("feed-row__head")], [ avatar_cluster(model, item), - html.span([attr.class("feed-row__label")], [ - text( - plural.count_noun(list.length(item.entries), "collector") - <> " picked up the same pressing", - ), + html.span([attr.class("feed-row__label"), attr.aria_label(label)], [ + text(label), ]), ]), cov.cover_row( @@ -256,13 +282,77 @@ fn avatar_cluster(model: Model, item: FeedItem) -> Element(Msg) { |> list.take(3) |> list.map(fn(pair) { let #(_, fe) = pair - html.span([attr.class("avatar avatar--sm")], [ - bar.avatar_content(None, fe.handle), - ]) + html.span( + [attr.class("avatar avatar--sm"), attr.aria_label("@" <> fe.handle)], + [ + bar.avatar_content(None, fe.handle), + ], + ) }) html.div([attr.class("feed-avatar-cluster")], avatars) } +fn converge_actors(model: Model, item: FeedItem) -> String { + let hydrated = hydrated_entries(model, item) + let handles = + hydrated + |> list.map(fn(pair) { + let #(_, fe) = pair + "@" <> fe.handle + }) + |> list.unique + let known = format_handles(handles) + let unresolved = unresolved_actor_count(item, hydrated) + case unresolved { + 0 -> known + count if known == "" -> plural.count_noun(count, "unresolved collector") + count -> + known <> ", plus " <> plural.count_noun(count, "unresolved collector") + } +} + +fn format_handles(handles: List(String)) -> String { + case handles { + [] -> "" + [only] -> only + [first, second, ..rest] -> + first + <> " and " + <> second + <> case list.length(rest) { + 0 -> "" + count -> " and " <> int.to_string(count) <> " more" + } + } +} + +fn hydrated_handles(hydrated: List(#(EntryRef, EntryDetail))) -> List(String) { + hydrated + |> list.map(fn(pair) { + let #(_, fe) = pair + "@" <> fe.handle + }) + |> list.unique +} + +fn unresolved_actor_count( + item: FeedItem, + hydrated: List(#(EntryRef, EntryDetail)), +) -> Int { + let known_actors = + hydrated + |> list.map(fn(pair) { + let #(ref, _) = pair + ref.actor + }) + |> list.unique + item.entries + |> list.map(fn(ref) { ref.actor }) + |> list.unique + |> list.filter(fn(actor) { !list.contains(known_actors, actor) }) + |> list.length +} + fn row_head(seed: String, label: String) -> Element(Msg) { html.div([attr.class("feed-row__head")], [ html.span([attr.class("avatar avatar--sm")], [ @@ -320,8 +410,9 @@ fn hydrated_entries( }) } -/// The designed empty state: unchanged from the pre-feed stub, shown whenever -/// the server returns no items at all (regardless of the fallback flag). +/// Suggested collectors need a read model containing known users plus follow +/// state. Connections owns that contract, so this remains browse-only until +/// that appview response is available rather than inventing local suggestions. fn empty_state() -> Element(Msg) { html.div([attr.class("body page-scroll")], [ html.div([attr.class("empty-state")], [ diff --git a/web/src/crate_web/pages/settings.gleam b/web/src/crate_web/pages/settings.gleam index 4104525..554ce2a 100644 --- a/web/src/crate_web/pages/settings.gleam +++ b/web/src/crate_web/pages/settings.gleam @@ -2,7 +2,9 @@ //// here from Add), a link into the edit inbox with its pending count, and log //// out. Reached by tapping the app-bar avatar. -import crate_web/model.{type Model, type Theme, EditInbox, LoggedIn} +import crate_web/model.{ + type Model, type Theme, ConnectionsFollowing, EditInbox, LoggedIn, +} import crate_web/msg.{ type Msg, ArmLogout, DiscogsConnect, DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, Logout, SetTheme, @@ -23,12 +25,30 @@ pub fn view(model: Model) -> Element(Msg) { html.div([attr.class("body page-scroll")], [ account_header(model), theme_row(model.theme), + connections_row(), discogs_account_view(model), inbox_row(model), logout_button(model.confirm_logout), ]) } +fn connections_row() -> Element(Msg) { + html.a( + [attr.class("settings-row"), attr.href(route.to_path(ConnectionsFollowing))], + [ + html.span([attr.class("settings-row__label")], [text("CONNECTIONS")]), + html.span([attr.class("settings-row__spacer")], []), + html.span( + [ + attr.class("settings-row__chevron"), + attr.attribute("aria-hidden", "true"), + ], + [text("›")], + ), + ], + ) +} + fn account_header(model: Model) -> Element(Msg) { let handle = current_handle(model) html.div([attr.class("settings-account")], [ diff --git a/web/src/crate_web/route.gleam b/web/src/crate_web/route.gleam index ee65241..530f243 100644 --- a/web/src/crate_web/route.gleam +++ b/web/src/crate_web/route.gleam @@ -1,9 +1,9 @@ //// URL <-> Route mapping for modem. import crate_web/model.{ - type Route, Add, Browse, Crate, EditInbox, EditProposalDetail, Feed, - PressingDetail, PublicCrate, PublicRecord, Record, RecordAmend, Scan, ScanDone, - ScanReview, Settings, + type Route, Add, Browse, ConnectionsFollowers, ConnectionsFollowing, Crate, + EditInbox, EditProposalDetail, Feed, PressingDetail, PublicCrate, PublicRecord, + Record, RecordAmend, Scan, ScanDone, ScanReview, Settings, } import gleam/uri.{type Uri} @@ -15,6 +15,8 @@ pub fn parse(target: Uri) -> Route { ["scan", "done"] -> ScanDone ["browse"] -> Browse ["feed"] -> Feed + ["connections", "followers"] -> ConnectionsFollowers + ["connections"] -> ConnectionsFollowing ["inbox"] -> EditInbox ["inbox", id] -> EditProposalDetail(id) ["settings"] -> Settings @@ -36,6 +38,8 @@ pub fn to_path(route: Route) -> String { ScanDone -> "/scan/done" Browse -> "/browse" Feed -> "/feed" + ConnectionsFollowing -> "/connections" + ConnectionsFollowers -> "/connections/followers" EditInbox -> "/inbox" EditProposalDetail(id) -> "/inbox/" <> id Settings -> "/settings" @@ -57,6 +61,7 @@ pub fn section(route: Route) -> Route { Crate Browse | PressingDetail(_, _) -> Browse Feed -> Feed + ConnectionsFollowing | ConnectionsFollowers -> Settings Settings | EditInbox | EditProposalDetail(_) -> Settings PublicCrate(_) | PublicRecord(_, _) -> route } diff --git a/web/src/crate_web/update.gleam b/web/src/crate_web/update.gleam index 2f63f04..95a36be 100644 --- a/web/src/crate_web/update.gleam +++ b/web/src/crate_web/update.gleam @@ -3,6 +3,7 @@ import crate_web/msg.{type Msg} import crate_web/update/add import crate_web/update/amend import crate_web/update/browse +import crate_web/update/connections import crate_web/update/feed import crate_web/update/inbox import crate_web/update/routing @@ -49,6 +50,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { msg.UseHandleSuggestion(suggestion) -> session.use_handle_suggestion(model, suggestion) msg.StartLogin -> session.start_login(model) + msg.Reauthorize -> session.reauthorize(model) msg.ArmLogout -> session.arm_logout(model) msg.DisarmLogout -> session.disarm_logout(model) msg.Logout -> session.logout_msg(model) @@ -136,10 +138,22 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { inbox.got_apply_proposal(model, uri, result) msg.IgnoreProposal(uri) -> inbox.ignore_proposal(model, uri) - msg.GotFeedSkeleton(result) -> feed.got_feed_skeleton(model, result) + msg.GotFeedSkeleton(request, result) -> + feed.got_feed_skeleton(model, request, result) msg.GotFeedEntry(actor, entry_id, result) -> feed.got_feed_entry(model, actor, entry_id, result) msg.FeedShowMore -> feed.feed_show_more(model) - msg.GotFeedMore(result) -> feed.got_feed_more(model, result) + msg.GotFeedMore(request, result) -> + feed.got_feed_more(model, request, result) + msg.GotConnections(request, result) -> + connections.got_connections(model, request, result) + msg.ConnectionsShowMore -> connections.show_more(model) + msg.RetryConnections -> connections.retry(model) + msg.ToggleConnectionFollow(did, currently_following) -> + connections.toggle_follow(model, did, currently_following) + msg.GotConnectionFollow(request, result) -> + connections.got_follow(model, request, result) + msg.GotConnectionUnfollow(request, result) -> + connections.got_unfollow(model, request, result) } } diff --git a/web/src/crate_web/update/browse.gleam b/web/src/crate_web/update/browse.gleam index 29c9303..5867fdb 100644 --- a/web/src/crate_web/update/browse.gleam +++ b/web/src/crate_web/update/browse.gleam @@ -9,7 +9,7 @@ import crate_web/model.{ OwnCrate, PressingFailed, PressingLoaded, ShelfLoaded, } import crate_web/msg.{type Msg} -import crate_web/update/common.{failed, write_error} +import crate_web/update/common.{failed, follow_write_error, write_error} import gleam/list import gleam/option.{type Option, None, Some} import gleam/string @@ -320,7 +320,7 @@ pub fn got_follow( Error(e) -> case model.overlap { Some(overlap) -> - write_error( + follow_write_error( Model( ..model, overlap: Some( @@ -330,7 +330,7 @@ pub fn got_follow( e, "Could not follow that user.", ) - None -> write_error(model, e, "Could not follow that user.") + None -> follow_write_error(model, e, "Could not follow that user.") } } } @@ -345,7 +345,7 @@ pub fn got_unfollow( Error(e) -> case model.overlap { Some(overlap) -> - write_error( + follow_write_error( Model( ..model, overlap: Some(CrateOverlap(..overlap, viewer_follows: True)), @@ -353,7 +353,7 @@ pub fn got_unfollow( e, "Could not unfollow that user.", ) - None -> write_error(model, e, "Could not unfollow that user.") + None -> follow_write_error(model, e, "Could not unfollow that user.") } } } diff --git a/web/src/crate_web/update/common.gleam b/web/src/crate_web/update/common.gleam index 0f72c5b..dd0fa64 100644 --- a/web/src/crate_web/update/common.gleam +++ b/web/src/crate_web/update/common.gleam @@ -20,6 +20,10 @@ pub fn failed(message: String) -> Option(Notice) { Some(Notice(Failure, message)) } +pub fn reauthorize(message: String) -> Option(Notice) { + Some(model.ReauthorizeNotice(message)) +} + /// The shared 401-vs-everything-else handling for a failed write: a session /// expiry signs the visitor out and clears their shelf, anything else just /// surfaces `fallback` as a failure notice. @@ -45,6 +49,28 @@ pub fn write_error( } } +/// Follow writes use a distinct 403 response when an existing OAuth session +/// lacks the graph.follow scope. Other writes keep the generic error path. +pub fn follow_write_error( + model: Model, + error: msg.ApiError, + fallback: String, +) -> #(Model, Effect(Msg)) { + case error { + xrpc.BadStatus(status: 403, ..) -> #( + Model( + ..model, + busy: False, + notice: reauthorize( + "This session cannot follow collectors yet. Sign in again to approve the follow permission.", + ), + ), + effect.none(), + ) + _ -> write_error(model, error, fallback) + } +} + /// The one hydrated-entry payload the record pages and the feed cache share, /// built from the `getEntry` response. pub fn detail_of(data: msg.EntryDetailData) -> EntryDetail { diff --git a/web/src/crate_web/update/connections.gleam b/web/src/crate_web/update/connections.gleam new file mode 100644 index 0000000..c7f65e7 --- /dev/null +++ b/web/src/crate_web/update/connections.gleam @@ -0,0 +1,247 @@ +import crate_web/effects.{ + follow_connection, load_connections, unfollow_connection, +} +import crate_web/model.{ + type ConnectionWrite, type ConnectionsRequest, type ConnectionsState, + type Model, Connection, ConnectionWrite, ConnectionsFailed, + ConnectionsFollowers, ConnectionsFollowing, ConnectionsLoaded, + ConnectionsLoading, ConnectionsRequest, Model, +} +import crate_web/msg.{type ConnectionsData, type Msg} +import crate_web/update/common.{failed, follow_write_error} +import gleam/list +import gleam/option.{type Option, None, Some} +import lustre/effect.{type Effect} + +pub fn got_connections( + model: Model, + request: ConnectionsRequest, + result: Result(ConnectionsData, msg.ApiError), +) -> #(Model, Effect(Msg)) { + case + model.route == request.route + && model.connections_generation == request.generation + { + False -> #(model, effect.none()) + True -> + case result { + Ok(data) -> { + let items = case model.connections_loading_more { + True -> + case model.connections { + ConnectionsLoaded(existing, _, _, _) -> + list.append( + existing, + model.append_new(existing, data.items, fn(item) { + Some(item.did) + }), + ) + _ -> data.items + } + False -> data.items + } + #( + Model( + ..model, + connections: ConnectionsLoaded( + items:, + cursor: data.cursor, + complete: data.complete, + relationship_complete: data.relationship_complete, + ), + connections_loading_more: False, + ), + effect.none(), + ) + } + Error(_) -> #( + Model( + ..model, + connections: case model.connections_loading_more { + True -> model.connections + False -> ConnectionsFailed + }, + connections_loading_more: False, + notice: failed("Couldn't load your connections."), + ), + effect.none(), + ) + } + } +} + +pub fn retry(model: Model) -> #(Model, Effect(Msg)) { + case connections_route(model.route) { + Some(route) -> { + let generation = model.connections_generation + 1 + let write_generation = model.connection_write_generation + 1 + let request = ConnectionsRequest(route, generation, None) + #( + Model( + ..model, + connections: ConnectionsLoading, + connections_generation: generation, + connection_write_generation: write_generation, + connection_pending: None, + connections_loading_more: False, + ), + load_connections(request), + ) + } + None -> #(model, effect.none()) + } +} + +pub fn show_more(model: Model) -> #(Model, Effect(Msg)) { + case connections_route(model.route), model.connections { + Some(route), ConnectionsLoaded(_, Some(cursor), _, _) + if !model.connections_loading_more + -> { + let generation = model.connections_generation + 1 + let request = ConnectionsRequest(route, generation, Some(cursor)) + #( + Model( + ..model, + connections_generation: generation, + connections_loading_more: True, + ), + load_connections(request), + ) + } + _, _ -> #(model, effect.none()) + } +} + +pub fn toggle_follow( + model: Model, + did: String, + currently_following: Bool, +) -> #(Model, Effect(Msg)) { + case + model.connection_pending, + connections_route(model.route), + update_item(model.connections, did, !currently_following) + { + None, Some(route), Some(updated) -> { + let generation = model.connection_write_generation + 1 + let request = + ConnectionWrite( + route:, + generation:, + did:, + desired: !currently_following, + ) + #( + Model( + ..model, + connections: updated, + connection_write_generation: generation, + connection_pending: Some(request), + notice: None, + ), + case currently_following { + True -> unfollow_connection(request) + False -> follow_connection(request) + }, + ) + } + _, _, _ -> #(model, effect.none()) + } +} + +pub fn got_follow( + model: Model, + request: ConnectionWrite, + result: Result(String, msg.ApiError), +) -> #(Model, Effect(Msg)) { + case + model.connection_pending == Some(request) && model.route == request.route, + result + { + False, _ -> #(model, effect.none()) + True, Ok(_) -> #(Model(..model, connection_pending: None), effect.none()) + True, Error(error) -> + revert( + model, + request.did, + request.desired, + error, + "Could not follow that user.", + ) + } +} + +pub fn got_unfollow( + model: Model, + request: ConnectionWrite, + result: Result(Nil, msg.ApiError), +) -> #(Model, Effect(Msg)) { + case + model.connection_pending == Some(request) && model.route == request.route, + result + { + False, _ -> #(model, effect.none()) + True, Ok(Nil) -> #(Model(..model, connection_pending: None), effect.none()) + True, Error(error) -> + revert( + model, + request.did, + request.desired, + error, + "Could not unfollow that user.", + ) + } +} + +fn revert( + model: Model, + did: String, + desired: Bool, + error: msg.ApiError, + message: String, +) -> #(Model, Effect(Msg)) { + follow_write_error( + Model( + ..model, + connections: update_item(model.connections, did, !desired) + |> option.unwrap(model.connections), + connection_pending: None, + ), + error, + message, + ) +} + +fn update_item( + state: ConnectionsState, + did: String, + follows: Bool, +) -> Option(ConnectionsState) { + case state { + ConnectionsLoaded(items, cursor, complete, relationship_complete) -> + Some(ConnectionsLoaded( + items: list.map(items, fn(item) { + case item.did == did { + True -> + Connection( + ..item, + viewer_follows: follows, + mutual: follows && item.follows_viewer, + ) + False -> item + } + }), + cursor:, + complete:, + relationship_complete:, + )) + _ -> None + } +} + +fn connections_route(route: model.Route) -> Option(model.Route) { + case route { + ConnectionsFollowing | ConnectionsFollowers -> Some(route) + _ -> None + } +} diff --git a/web/src/crate_web/update/feed.gleam b/web/src/crate_web/update/feed.gleam index e14375c..d9b2313 100644 --- a/web/src/crate_web/update/feed.gleam +++ b/web/src/crate_web/update/feed.gleam @@ -1,23 +1,31 @@ import crate/gen/feed/get_feed_skeleton.{type FeedItem} import crate_web/effects.{load_entry, load_feed} -import crate_web/model.{type Model, FeedFailed, FeedLoaded, Model} +import crate_web/model.{ + type FeedRequest, type Model, Feed, FeedFailed, FeedLoaded, FeedRequest, Model, +} import crate_web/msg.{type FeedSkeletonData, type Msg, GotFeedEntry} import crate_web/update/common.{detail_of, failed} import gleam/dict import gleam/list -import gleam/option.{Some} +import gleam/option.{None, Some} import lustre/effect.{type Effect} pub fn got_feed_skeleton( model: Model, + request: FeedRequest, result: Result(FeedSkeletonData, msg.ApiError), ) -> #(Model, Effect(Msg)) { - case result { - Ok(data) -> #( - Model(..model, feed: FeedLoaded(data.items, data.cursor, data.fallback)), + case current_skeleton_request(model, request), result { + False, _ -> #(model, effect.none()) + True, Ok(data) -> #( + Model( + ..model, + feed: FeedLoaded(data.items, data.cursor, data.fallback), + feed_loading_more: False, + ), hydrate_uncached(data.items, model.feed_entries), ) - Error(_) -> #( + True, Error(_) -> #( Model( ..model, feed: FeedFailed, @@ -53,21 +61,27 @@ pub fn got_feed_entry( } pub fn feed_show_more(model: Model) -> #(Model, Effect(Msg)) { - case model.feed { - FeedLoaded(_, Some(cursor), _) if !model.feed_loading_more -> #( - Model(..model, feed_loading_more: True), - load_feed(Some(cursor)), - ) - _ -> #(model, effect.none()) + case model.route, model.feed { + Feed, FeedLoaded(_, Some(cursor), _) if !model.feed_loading_more -> { + let generation = model.feed_generation + 1 + let request = FeedRequest(Feed, generation, Some(cursor)) + #( + Model(..model, feed_generation: generation, feed_loading_more: True), + load_feed(request), + ) + } + _, _ -> #(model, effect.none()) } } pub fn got_feed_more( model: Model, + request: FeedRequest, result: Result(FeedSkeletonData, msg.ApiError), ) -> #(Model, Effect(Msg)) { - case result { - Ok(data) -> + case current_more_request(model, request), result { + False, _ -> #(model, effect.none()) + True, Ok(data) -> case model.feed { FeedLoaded(items, _, _) -> { let fresh = model.append_new(items, data.items, model.feed_item_key) @@ -86,7 +100,7 @@ pub fn got_feed_more( } _ -> #(Model(..model, feed_loading_more: False), effect.none()) } - Error(_) -> #( + True, Error(_) -> #( Model( ..model, feed_loading_more: False, @@ -97,6 +111,26 @@ pub fn got_feed_more( } } +fn current_skeleton_request(model: Model, request: FeedRequest) -> Bool { + model.route == request.route + && request.route == Feed + && model.feed_generation == request.generation + && request.cursor == None +} + +fn current_more_request(model: Model, request: FeedRequest) -> Bool { + model.route == request.route + && request.route == Feed + && model.feed_generation == request.generation + && request.cursor != None + && model.feed_loading_more + && case model.feed, request.cursor { + FeedLoaded(_, cursor, _), Some(request_cursor) -> + cursor == Some(request_cursor) + _, _ -> False + } +} + /// Fan out hydration for every entry ref in a batch of feed items not /// already sitting in the cache, so a re-fetched or overlapping item never /// re-hydrates a ref that's already loaded. diff --git a/web/src/crate_web/update/routing.gleam b/web/src/crate_web/update/routing.gleam index 7c51f95..c077c45 100644 --- a/web/src/crate_web/update/routing.gleam +++ b/web/src/crate_web/update/routing.gleam @@ -2,8 +2,9 @@ import crate_web/effects.{ discogs_status, load_browse, load_entry, load_feed, load_shelf, } import crate_web/model.{ - type Model, Add, Browse, EditInbox, EditProposalDetail, Feed, FeedLoading, - Model, PressingDetail, PressingLoading, PublicCrate, PublicRecord, Record, + type Model, Add, Browse, ConnectionsFollowers, ConnectionsFollowing, + ConnectionsLoading, EditInbox, EditProposalDetail, Feed, FeedLoading, Model, + PressingDetail, PressingLoading, PublicCrate, PublicRecord, Record, RecordAmend, Scan, ScanDone, crate_of, set_crate_loading_if_absent, } import crate_web/msg.{type Msg, GotEntry} @@ -125,6 +126,9 @@ pub fn on_route_change( // only on entry (never at login) and clears any stale in-flight add. route -> { let scan_state = scan.scan_for_route(model.scan, model.route, route) + let entering_connections = is_connections_route(route) + let leaving_connections = is_connections_route(model.route) + let feed_route_changed = route == Feed || model.route == Feed let updated = Model( ..model, @@ -164,6 +168,27 @@ pub fn on_route_change( Feed -> FeedLoading _ -> model.feed }, + feed_generation: case feed_route_changed { + True -> model.feed_generation + 1 + False -> model.feed_generation + }, + feed_loading_more: False, + connections: case route { + ConnectionsFollowing | ConnectionsFollowers -> ConnectionsLoading + _ -> model.connections + }, + connections_generation: case entering_connections { + True -> model.connections_generation + 1 + False -> model.connections_generation + }, + connection_write_generation: case + entering_connections || leaving_connections + { + True -> model.connection_write_generation + 1 + False -> model.connection_write_generation + }, + connection_pending: None, + connections_loading_more: False, ) // A cached actor crate stays put (stale-while-revalidate): the fetch // below still fires and refreshes it once it lands, but a fresh @@ -186,7 +211,14 @@ pub fn on_route_change( effects.scan_seen(), ]) Browse -> load_browse() - Feed -> load_feed(None) + Feed -> + load_feed(model.FeedRequest(Feed, updated.feed_generation, None)) + ConnectionsFollowing | ConnectionsFollowers -> + effects.load_connections(model.ConnectionsRequest( + route, + updated.connections_generation, + None, + )) EditInbox | EditProposalDetail(_) -> effects.load_edit_inbox() PublicCrate(handle) -> effect.batch([ @@ -203,6 +235,13 @@ pub fn on_route_change( } } +fn is_connections_route(route: model.Route) -> Bool { + case route { + ConnectionsFollowing | ConnectionsFollowers -> True + _ -> False + } +} + /// Both armed confirms reset on every route change; drop their outside-click /// watchers too so a stale one never lingers on a button that just left the /// page. diff --git a/web/src/crate_web/update/session.gleam b/web/src/crate_web/update/session.gleam index a466a95..27415fd 100644 --- a/web/src/crate_web/update/session.gleam +++ b/web/src/crate_web/update/session.gleam @@ -57,6 +57,20 @@ pub fn start_login(model: Model) -> #(Model, Effect(Msg)) { } } +/// Start a fresh OAuth flow with the current handle. Existing sessions cannot +/// be upgraded in place because the authorization server must grant the new +/// repository scope explicitly. +pub fn reauthorize(model: Model) -> #(Model, Effect(Msg)) { + case model.auth { + model.LoggedIn(handle) -> + case string.trim(handle) { + "" -> #(model, effect.none()) + _ -> #(Model(..model, notice: None), oauth_login(handle)) + } + _ -> #(model, effect.none()) + } +} + pub fn arm_logout(model: Model) -> #(Model, Effect(Msg)) { #( Model(..model, confirm_logout: True), diff --git a/web/src/crate_web/view.gleam b/web/src/crate_web/view.gleam index 5890d2f..88c7061 100644 --- a/web/src/crate_web/view.gleam +++ b/web/src/crate_web/view.gleam @@ -2,15 +2,17 @@ //// modules. import crate_web/model.{ - type Model, type Notice, type NoticeLevel, Add, Browse, Crate, EditInbox, - EditProposalDetail, EntryDetailFailed, EntryDetailLoaded, Failure, Feed, Info, - LoggedIn, LoggedOut, Notice, PressingDetail, PublicCrate, PublicRecord, Record, + type Model, type Notice, type NoticeLevel, Add, Browse, ConnectionsFollowers, + ConnectionsFollowing, Crate, EditInbox, EditProposalDetail, EntryDetailFailed, + EntryDetailLoaded, Failure, Feed, Info, LoggedIn, LoggedOut, Notice, + PressingDetail, PublicCrate, PublicRecord, ReauthorizeNotice, Record, RecordAmend, Scan, ScanDone, ScanReview, Settings, Success, Warning, inbox_pending_count, } -import crate_web/msg.{type Msg, Back, ClearNotice} +import crate_web/msg.{type Msg, Back, ClearNotice, Reauthorize} import crate_web/pages/add import crate_web/pages/browse +import crate_web/pages/connections import crate_web/pages/crate import crate_web/pages/edit_inbox import crate_web/pages/edit_proposal @@ -118,6 +120,10 @@ fn page(model: Model) -> Element(Msg) { Crate -> crate.view(model) Browse -> browse.view(model) Feed -> feed.view(model) + ConnectionsFollowing -> + sub_page("CONNECTIONS", element.none(), connections.view(model, False)) + ConnectionsFollowers -> + sub_page("CONNECTIONS", element.none(), connections.view(model, True)) Settings -> settings.view(model) Add -> sub_page("ADD RECORD", element.none(), add.view(model)) Scan -> @@ -232,30 +238,54 @@ fn bottom_bar(model: Model) -> Element(Msg) { fn notice_view(notice: Option(Notice)) -> Element(Msg) { case notice { - Some(Notice(level, message)) -> + Some(ReauthorizeNotice(message)) -> html.div( [ - attr.class("notice notice--" <> level_class(level)), + attr.class("notice notice--warning"), attr.role("alert"), ], [ - html.span([attr.class("notice__icon")], [text(level_icon(level))]), + html.span([attr.class("notice__icon")], [text("!")]), html.span([attr.class("notice__msg")], [text(message)]), html.button( [ - attr.class("notice__dismiss"), + attr.class("notice__action"), attr.type_("button"), - attr.attribute("aria-label", "Dismiss"), - event.on_click(ClearNotice), + event.on_click(Reauthorize), ], - [text("✕")], + [text("REAUTHORIZE")], ), + dismiss_notice(), + ], + ) + Some(Notice(level, message)) -> + html.div( + [ + attr.class("notice notice--" <> level_class(level)), + attr.role("alert"), + ], + [ + html.span([attr.class("notice__icon")], [text(level_icon(level))]), + html.span([attr.class("notice__msg")], [text(message)]), + dismiss_notice(), ], ) None -> element.none() } } +fn dismiss_notice() -> Element(Msg) { + html.button( + [ + attr.class("notice__dismiss"), + attr.type_("button"), + attr.attribute("aria-label", "Dismiss"), + event.on_click(ClearNotice), + ], + [text("✕")], + ) +} + fn level_class(level: NoticeLevel) -> String { case level { Success -> "success" diff --git a/web/test/connections_test.gleam b/web/test/connections_test.gleam new file mode 100644 index 0000000..e0ab1ba --- /dev/null +++ b/web/test/connections_test.gleam @@ -0,0 +1,279 @@ +import atproto_core/xrpc +import crate_web/model.{ + type Connection, type Model, Connection, ConnectionsFailed, + ConnectionsFollowers, ConnectionsFollowing, ConnectionsLoaded, + ConnectionsLoading, ConnectionsRequest, Model, ReauthorizeNotice, +} +import crate_web/msg.{ + ConnectionsData, GotConnectionFollow, GotConnections, OnRouteChange, + RetryConnections, ToggleConnectionFollow, +} +import crate_web/pages/connections +import crate_web/route +import crate_web/update.{update} +import gleam/option.{None, Some} +import gleam/string +import gleam/uri +import lustre/element +import support.{empty_effect, logged_in, network_error} + +pub fn connections_routes_round_trip_test() { + assert route.to_path(ConnectionsFollowers) == "/connections/followers" + let assert Ok(target) = uri.parse("/connections/followers") + assert route.parse(target) == ConnectionsFollowers + let assert Ok(default) = uri.parse("/connections") + assert route.parse(default) == model.ConnectionsFollowing +} + +pub fn followers_empty_state_explains_index_incompleteness_test() { + let html = + connections.view( + model.Model( + ..logged_in(), + connections: ConnectionsLoaded([], None, False, True), + ), + True, + ) + |> element.to_string + assert string.contains(html, "No indexed followers yet") + assert string.contains(html, "appview may still be catching up") + assert !string.contains(html, "No one follows you yet") +} + +pub fn connection_row_exposes_mutual_and_accessible_follow_action_test() { + let html = + connections.view( + model.Model( + ..logged_in(), + connections: ConnectionsLoaded( + [ + Connection( + did: "did:plc:bob", + handle: "bob.test", + viewer_follows: False, + follows_viewer: True, + mutual: False, + ), + ], + None, + False, + True, + ), + ), + True, + ) + |> element.to_string + assert string.contains(html, "@bob.test") + assert string.contains(html, "Follow @bob.test") + assert string.contains(html, "FOLLOW") + assert string.contains(html, "aria-current=\"page\"") +} + +pub fn connection_loading_and_failure_states_are_distinct_test() { + let loading = + connections.view( + model.Model(..logged_in(), connections: ConnectionsLoading), + False, + ) + |> element.to_string + let failed = + connections.view( + model.Model(..logged_in(), connections: ConnectionsFailed), + False, + ) + |> element.to_string + assert string.contains(loading, "LOADING CONNECTIONS") + assert string.contains(failed, "RETRY") + assert !string.contains(loading, "RETRY") +} + +pub fn stale_same_route_list_response_is_ignored_after_retry_test() { + let old = ConnectionsRequest(ConnectionsFollowing, 4, None) + let current = ConnectionsRequest(ConnectionsFollowing, 5, None) + let seeded = + Model( + ..logged_in(), + route: ConnectionsFollowing, + connections_generation: old.generation, + connections: ConnectionsLoaded([connection("old")], None, True, True), + ) + let #(reloading, _) = update(seeded, RetryConnections) + assert reloading.connections_generation == current.generation + let #(after_stale, _) = + update( + reloading, + GotConnections( + old, + Ok(ConnectionsData([connection("stale")], None, True, True)), + ), + ) + assert after_stale.connections == ConnectionsLoading + let #(loaded, _) = + update( + after_stale, + GotConnections( + current, + Ok(ConnectionsData([connection("fresh")], None, True, True)), + ), + ) + assert loaded.connections + == ConnectionsLoaded([connection("fresh")], None, True, True) +} + +pub fn pagination_response_appends_only_for_current_generation_test() { + let old = ConnectionsRequest(ConnectionsFollowing, 8, Some("cursor-1")) + let current = ConnectionsRequest(ConnectionsFollowing, 9, Some("cursor-1")) + let seeded = + Model( + ..logged_in(), + route: ConnectionsFollowing, + connections_generation: current.generation, + connections: ConnectionsLoaded( + [connection("first")], + Some("cursor-1"), + False, + True, + ), + connections_loading_more: True, + ) + let #(after_stale, _) = + update( + seeded, + GotConnections( + old, + Ok(ConnectionsData([connection("stale")], None, True, True)), + ), + ) + assert after_stale.connections == seeded.connections + let #(loaded, _) = + update( + after_stale, + GotConnections( + current, + Ok(ConnectionsData([connection("second")], None, True, True)), + ), + ) + assert loaded.connections + == ConnectionsLoaded( + [connection("first"), connection("second")], + None, + True, + True, + ) + assert loaded.connections_loading_more == False +} + +pub fn optimistic_follow_success_clears_pending_write_test() { + let seeded = loaded_follow_state(False) + let #(optimistic, _) = + update(seeded, ToggleConnectionFollow("did:bob", False)) + let assert Some(request) = optimistic.connection_pending + assert request.did == "did:bob" + assert request.desired == True + assert connection_follows(optimistic) == True + let #(confirmed, _) = + update(optimistic, GotConnectionFollow(request, Ok("at://alice/follow/1"))) + assert confirmed.connection_pending == None + assert connection_follows(confirmed) == True +} + +pub fn optimistic_follow_error_rolls_back_test() { + let seeded = loaded_follow_state(False) + let #(optimistic, _) = + update(seeded, ToggleConnectionFollow("did:bob", False)) + let assert Some(request) = optimistic.connection_pending + let #(rolled_back, _) = + update(optimistic, GotConnectionFollow(request, Error(network_error()))) + assert rolled_back.connection_pending == None + assert connection_follows(rolled_back) == False + assert rolled_back.notice != None +} + +pub fn stale_follow_completion_after_same_route_reload_is_ignored_test() { + let seeded = loaded_follow_state(False) + let #(optimistic, _) = + update(seeded, ToggleConnectionFollow("did:bob", False)) + let assert Some(request) = optimistic.connection_pending + let #(reloaded, _) = update(optimistic, OnRouteChange(ConnectionsFollowing)) + assert reloaded.connection_pending == None + let #(after_stale, _) = + update(reloaded, GotConnectionFollow(request, Error(network_error()))) + assert after_stale.connections == ConnectionsLoading + assert after_stale.connection_pending == None +} + +fn connection(handle: String) -> Connection { + Connection( + did: "did:" <> handle, + handle: handle <> ".test", + viewer_follows: False, + follows_viewer: False, + mutual: False, + ) +} + +fn loaded_follow_state(follows: Bool) -> Model { + Model( + ..logged_in(), + route: ConnectionsFollowing, + connections: ConnectionsLoaded( + [ + Connection( + did: "did:bob", + handle: "bob.test", + viewer_follows: follows, + follows_viewer: False, + mutual: False, + ), + ], + None, + True, + True, + ), + ) +} + +fn connection_follows(model: Model) -> Bool { + case model.connections { + ConnectionsLoaded([first, ..], _, _, _) -> first.viewer_follows + _ -> False + } +} + +pub fn missing_follow_permission_offers_reauthorization_in_connections_test() { + let did = "did:plc:bob" + let model = + model.Model( + ..logged_in(), + route: ConnectionsFollowing, + connections: ConnectionsLoaded( + [ + Connection( + did:, + handle: "bob.test", + viewer_follows: False, + follows_viewer: False, + mutual: False, + ), + ], + None, + True, + True, + ), + ) + let #(pending, effect) = update(model, ToggleConnectionFollow(did, False)) + assert effect != empty_effect() + let assert Some(request) = pending.connection_pending + let #(updated, _) = + update( + pending, + GotConnectionFollow( + request, + Error(xrpc.BadStatus(403, Some("InsufficientScope"), None, "")), + ), + ) + assert updated.notice + == Some(ReauthorizeNotice( + "This session cannot follow collectors yet. Sign in again to approve the follow permission.", + )) +} diff --git a/web/test/feed_test.gleam b/web/test/feed_test.gleam index 1493c73..e46cc5b 100644 --- a/web/test/feed_test.gleam +++ b/web/test/feed_test.gleam @@ -7,8 +7,8 @@ import crate/gen/feed/get_feed_skeleton.{ } import crate/gen/shelf/list_entries import crate_web/model.{ - type EntryDetail, type Model, EntryDetail, Feed, FeedFailed, FeedLoaded, - FeedLoading, Model, + type EntryDetail, type FeedRequest, type Model, Browse, EntryDetail, Feed, + FeedFailed, FeedLoaded, FeedLoading, FeedRequest, Model, } import crate_web/msg.{ EntryDetailData, FeedShowMore, FeedSkeletonData, GotFeedEntry, GotFeedMore, @@ -93,6 +93,14 @@ fn render(model: Model) -> String { feed.view(model) |> element.to_string } +fn feed_model() -> Model { + Model(..logged_in(), route: Feed) +} + +fn feed_request(cursor: option.Option(String)) -> FeedRequest { + FeedRequest(Feed, 0, cursor) +} + fn occurrences(haystack: String, needle: String) -> Int { list.length(string.split(haystack, needle)) - 1 } @@ -124,7 +132,10 @@ pub fn feed_failed_state_shows_the_couldnt_load_copy_test() { pub fn got_feed_skeleton_error_marks_failed_and_notices_test() { let #(model, _) = - update(logged_in(), GotFeedSkeleton(Error(support.network_error()))) + update( + feed_model(), + GotFeedSkeleton(feed_request(None), Error(support.network_error())), + ) assert model.feed == FeedFailed assert case model.notice { Some(model.Notice(model.Failure, _)) -> True @@ -161,6 +172,21 @@ pub fn actor_batch_hydrated_renders_count_and_strip_test() { assert string.contains(html, "class=\"feed-strip\"") } +pub fn actor_batch_partial_hydration_does_not_credit_later_actor_with_full_batch_test() { + let refs = [ref("did:a", "e0"), ref("did:b", "e1")] + let html = + render( + with_feed([batch(refs, "added")], False, [ + #(#("did:b", "e1"), fe("bob.test", "2026-07-18T11:00:00Z")), + ]), + ) + assert string.contains( + html, + "@bob.test added 1 record, plus 1 unresolved record", + ) + assert !string.contains(html, "@bob.test added 2 records") +} + pub fn subject_converge_hydrated_links_to_the_pressing_test() { let refs = [ref("did:a", "e0"), ref("did:b", "e9")] let subject = "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1" @@ -168,9 +194,32 @@ pub fn subject_converge_hydrated_links_to_the_pressing_test() { render( with_feed([converge(refs, subject)], False, [ #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + #(#("did:b", "e9"), fe("bob.test", "2026-07-18T11:00:00Z")), ]), ) - assert string.contains(html, "2 collectors picked up the same pressing") + assert string.contains( + html, + "@alice.test and @bob.test picked up the same pressing", + ) + assert string.contains(html, "class=\"feed-avatar-cluster\"") + assert string.contains(html, "aria-label=\"@alice.test\"") + assert string.contains(html, "aria-label=\"@bob.test\"") + assert string.contains(html, "href=\"/pressing/did:x/rk1\"") +} + +pub fn subject_converge_partial_hydration_names_known_and_unresolved_test() { + let refs = [ref("did:a", "e0"), ref("did:b", "e9"), ref("did:c", "e8")] + let subject = "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1" + let html = + render( + with_feed([converge(refs, subject)], False, [ + #(#("did:b", "e9"), fe("bob.test", "2026-07-18T11:00:00Z")), + ]), + ) + let label = + "@bob.test, plus 2 unresolved collectors picked up the same pressing" + assert string.contains(html, label) + assert string.contains(html, "aria-label=\"" <> label <> "\"") assert string.contains(html, "class=\"feed-avatar-cluster\"") assert string.contains(html, "href=\"/pressing/did:x/rk1\"") } @@ -191,6 +240,21 @@ pub fn import_reason_via_line_reflects_the_source_test() { assert !string.contains(without_source, " via ") } +pub fn import_partial_hydration_uses_unresolved_copy_test() { + let refs = [ref("did:a", "e0"), ref("did:b", "e1")] + let html = + render( + with_feed([import_item(refs, Some("discogs"))], False, [ + #(#("did:b", "e1"), fe("bob.test", "2026-07-18T11:00:00Z")), + ]), + ) + assert string.contains( + html, + "@bob.test imported 1 record, plus 1 unresolved record via Discogs", + ) + assert !string.contains(html, "@bob.test imported 2 records via Discogs") +} + // --- reason rows: unhydrated (skeleton, no crash) ----------------------- pub fn every_reason_renders_a_skeleton_when_unhydrated_test() { @@ -256,7 +320,8 @@ pub fn on_route_change_feed_sets_loading_and_fires_a_load_test() { pub fn got_feed_skeleton_stores_items_and_hydrates_test() { let data = FeedSkeletonData([single("did:a", "e0", "added")], False, None) - let #(model, effect) = update(logged_in(), GotFeedSkeleton(Ok(data))) + let #(model, effect) = + update(feed_model(), GotFeedSkeleton(feed_request(None), Ok(data))) let assert FeedLoaded(items, _, _) = model.feed assert list.length(items) == 1 // Uncached refs mean a real hydration effect fires. @@ -265,7 +330,8 @@ pub fn got_feed_skeleton_stores_items_and_hydrates_test() { pub fn got_feed_skeleton_with_no_items_fires_no_hydration_test() { let data = FeedSkeletonData([], False, None) - let #(_, effect) = update(logged_in(), GotFeedSkeleton(Ok(data))) + let #(_, effect) = + update(feed_model(), GotFeedSkeleton(feed_request(None), Ok(data))) assert effect == empty_effect() } @@ -295,7 +361,7 @@ pub fn got_feed_entry_error_leaves_the_cache_untouched_test() { pub fn got_feed_more_appends_and_dedupes_by_first_ref_test() { let seeded = Model( - ..logged_in(), + ..feed_model(), feed: FeedLoaded( [single("did:a", "e0", "added"), single("did:a", "e1", "added")], Some("cursor1"), @@ -310,7 +376,8 @@ pub fn got_feed_more_appends_and_dedupes_by_first_ref_test() { False, None, ) - let #(updated, _) = update(seeded, GotFeedMore(Ok(next))) + let #(updated, _) = + update(seeded, GotFeedMore(feed_request(Some("cursor1")), Ok(next))) let assert FeedLoaded(items, _, _) = updated.feed assert item_keys(items) == [#("did:a", "e0"), #("did:a", "e1"), #("did:a", "e2")] @@ -333,13 +400,64 @@ pub fn feed_show_more_fetches_only_with_a_cursor_and_not_already_loading_test() |> list.each(fn(row) { let #(feed_state, loading, expect_fetch) = row let seeded = - Model(..logged_in(), feed: feed_state, feed_loading_more: loading) + Model(..feed_model(), feed: feed_state, feed_loading_more: loading) let #(_, effect) = update(seeded, FeedShowMore) // A fetch fires only when a cursor exists and none is already in flight. assert { effect != empty_effect() } == expect_fetch }) } +pub fn stale_feed_skeleton_does_not_replace_the_current_route_test() { + let current = + Model( + ..feed_model(), + feed_generation: 2, + feed: FeedLoaded([single("did:a", "e0", "current")], None, False), + ) + let stale = FeedSkeletonData([single("did:b", "e1", "stale")], False, None) + let #(updated, effect) = + update(current, GotFeedSkeleton(FeedRequest(Feed, 1, None), Ok(stale))) + assert updated == current + assert effect == empty_effect() +} + +pub fn stale_feed_more_does_not_append_or_clear_loading_state_test() { + let current = + Model( + ..feed_model(), + feed_generation: 2, + feed: FeedLoaded( + [single("did:a", "e0", "current")], + Some("cursor2"), + False, + ), + feed_loading_more: True, + ) + let stale = FeedSkeletonData([single("did:b", "e1", "stale")], False, None) + let #(updated, effect) = + update( + current, + GotFeedMore(FeedRequest(Feed, 1, Some("cursor1")), Ok(stale)), + ) + assert updated == current + assert effect == empty_effect() +} + +pub fn route_transition_clears_feed_loading_state_test() { + let current = + Model( + ..feed_model(), + feed_loading_more: True, + feed: FeedLoaded( + [single("did:a", "e0", "current")], + Some("cursor"), + False, + ), + ) + let #(updated, _) = update(current, OnRouteChange(Browse)) + assert updated.feed_loading_more == False +} + // --- pure helpers -------------------------------------------------------- pub fn feed_item_day_takes_the_newest_hydrated_day_test() { diff --git a/web/test/login_test.gleam b/web/test/login_test.gleam index 8029c25..cdf66e2 100644 --- a/web/test/login_test.gleam +++ b/web/test/login_test.gleam @@ -1,5 +1,7 @@ -import crate_web/model.{LoggedOut, OwnCrate, ShelfLoading, crate_of} -import crate_web/msg.{GotAvatar, GotLogout, Logout} +import crate_web/model.{ + LoggedOut, Model, OwnCrate, ReauthorizeNotice, ShelfLoading, crate_of, +} +import crate_web/msg.{GotAvatar, GotLogout, Logout, Reauthorize} import crate_web/pages/login import crate_web/update.{update} import gleam/option.{None, Some} @@ -73,3 +75,11 @@ pub fn got_avatar_stores_it_and_logout_clears_it_test() { let #(after_logout, _) = update(with_avatar, Logout) assert after_logout.avatar == None } + +pub fn reauthorize_uses_the_current_handle_and_clears_the_notice_test() { + let model = + Model(..logged_in(), notice: Some(ReauthorizeNotice("needs scope"))) + let #(updated, effect) = update(model, Reauthorize) + assert updated.notice == None + assert effect != support.empty_effect() +} diff --git a/web/test/public_crate_test.gleam b/web/test/public_crate_test.gleam index 41a895b..a5dc133 100644 --- a/web/test/public_crate_test.gleam +++ b/web/test/public_crate_test.gleam @@ -1,9 +1,11 @@ //// Public-route tests: `/u/:handle` and `/u/:handle/record/:entryId`. +import atproto_core/xrpc import crate_web/model.{ type Model, Actor, ActorCrate, CrateOverlap, EntryDetailFailed, EntryDetailLoaded, EntryDetailLoading, LoggedIn, LoggedOut, Model, PublicCrate, - PublicRecord, ShelfFailed, ShelfLoaded, ShelfLoading, crate_of, set_crate, + PublicRecord, ReauthorizeNotice, ShelfFailed, ShelfLoaded, ShelfLoading, + crate_of, set_crate, } import crate_web/msg.{ CopyRecordLink, CrateOverlapData, EntryDetailData, GotActorShelf, @@ -367,6 +369,20 @@ pub fn got_follow_error_reverts_the_optimistic_flip_test() { assert updated.notice != None } +pub fn missing_follow_permission_offers_reauthorization_test() { + let model = model_with_overlap(CrateOverlap(0, None, 0, True, None)) + let #(updated, _) = + update( + model, + GotFollow(Error(xrpc.BadStatus(403, Some("InsufficientScope"), None, ""))), + ) + assert updated.overlap == Some(CrateOverlap(0, None, 0, False, None)) + assert updated.notice + == Some(ReauthorizeNotice( + "This session cannot follow collectors yet. Sign in again to approve the follow permission.", + )) +} + pub fn got_unfollow_ok_leaves_the_optimistic_clear_in_place_test() { let model = model_with_overlap(CrateOverlap(0, None, 0, False, None)) let #(updated, _) = update(model, GotUnfollow(Ok(Nil))) diff --git a/web/test/support.gleam b/web/test/support.gleam index 65cb35a..6accb64 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -78,6 +78,12 @@ pub fn base() -> Model { overlap: None, pressing: model.PressingLoading, feed: model.FeedLoading, + feed_generation: 0, + connections: model.ConnectionsLoading, + connections_generation: 0, + connection_write_generation: 0, + connection_pending: None, + connections_loading_more: False, feed_entries: dict.new(), feed_loading_more: False, nav_depth: 0, -- 2.51.2 From b948bf9fe2112f65718e10d6b69b829d5b8effd3 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 9 Aug 2026 09:36:00 +0200 Subject: [PATCH 02/16] test: seed twenty social collectors --- e2e/devnet/Caddyfile | 2 +- e2e/devnet/create-test-accounts.sh | 20 ++++++++- e2e/devnet/docker-compose.yml | 15 +++++++ e2e/devnet/seed-social.sh | 70 +++++++++++++++++++++++++++++- e2e/tests/adoption.spec.ts | 3 +- e2e/tests/edit-inbox.spec.ts | 3 +- e2e/tests/oauth-error.spec.ts | 4 +- 7 files changed, 109 insertions(+), 8 deletions(-) diff --git a/e2e/devnet/Caddyfile b/e2e/devnet/Caddyfile index 4db2a9d..275349a 100644 --- a/e2e/devnet/Caddyfile +++ b/e2e/devnet/Caddyfile @@ -3,7 +3,7 @@ # internal` — no mkcert or other external tooling needed. } -alice.test, bob.test, carol.test, dave.test, erin.test { +alice.test, bob.test, carol.test, dave.test, erin.test, faye.test, gideon.test, hana.test, ivan.test, june.test, kai.test, lina.test, malik.test, nora.test, otis.test, priya.test, quinn.test, rosa.test, sam.test, theo.test { tls internal reverse_proxy pds:3000 } diff --git a/e2e/devnet/create-test-accounts.sh b/e2e/devnet/create-test-accounts.sh index 5654ecc..9baf985 100755 --- a/e2e/devnet/create-test-accounts.sh +++ b/e2e/devnet/create-test-accounts.sh @@ -6,12 +6,15 @@ set -euo pipefail PASSWORD="${TEST_ACCOUNT_PASSWORD:-e2e-test-password}" +ALICE_PASSWORD="${ALICE_TEST_ACCOUNT_PASSWORD:-alice}" create() { local handle="$1" + local password="$PASSWORD" + [[ "$handle" == "alice.test" ]] && password="$ALICE_PASSWORD" echo "creating $handle..." docker exec crate-devnet-pds-1 goat pds admin account create \ - --handle "$handle" --password "$PASSWORD" --email "${handle%.test}@example.com" \ + --handle "$handle" --password "$password" --email "${handle%.test}@example.com" \ || echo " (already exists, or creation failed — check above)" } @@ -20,3 +23,18 @@ create bob.test create carol.test create dave.test create erin.test +create faye.test +create gideon.test +create hana.test +create ivan.test +create june.test +create kai.test +create lina.test +create malik.test +create nora.test +create otis.test +create priya.test +create quinn.test +create rosa.test +create sam.test +create theo.test diff --git a/e2e/devnet/docker-compose.yml b/e2e/devnet/docker-compose.yml index a74347e..27ac020 100644 --- a/e2e/devnet/docker-compose.yml +++ b/e2e/devnet/docker-compose.yml @@ -115,6 +115,21 @@ services: - carol.test - dave.test - erin.test + - faye.test + - gideon.test + - hana.test + - ivan.test + - june.test + - kai.test + - lina.test + - malik.test + - nora.test + - otis.test + - priya.test + - quinn.test + - rosa.test + - sam.test + - theo.test # Caddy's `tls internal` CA is generated into its own data volume; export # the root cert so slingshot (below) can be told to trust it. diff --git a/e2e/devnet/seed-social.sh b/e2e/devnet/seed-social.sh index 0bf8548..f168740 100755 --- a/e2e/devnet/seed-social.sh +++ b/e2e/devnet/seed-social.sh @@ -21,6 +21,7 @@ fi PDS="${PDS_URL:-http://localhost:2584}" PASSWORD="${TEST_ACCOUNT_PASSWORD:-e2e-test-password}" +ALICE_PASSWORD="${ALICE_TEST_ACCOUNT_PASSWORD:-alice}" # Every string value below is JSON-encoded through this instead of hand-quoted, # so a title/artist/note containing a quote, backslash, or newline can never @@ -46,9 +47,12 @@ T2="$(ts_offset $((NOW_EPOCH + 7200)))" T_PAST="$(ts_offset $((NOW_EPOCH - 90000)))" session() { + local handle="$1" + local password="$PASSWORD" + [[ "$handle" == "alice.test" ]] && password="$ALICE_PASSWORD" curl -sf "$PDS/xrpc/com.atproto.server.createSession" \ -H 'content-type: application/json' \ - -d "{\"identifier\":$(json_str "$1"),\"password\":$(json_str "$PASSWORD")}" + -d "{\"identifier\":$(json_str "$handle"),\"password\":$(json_str "$password")}" } did_of() { echo "$1" | grep -o '"did":"[^"]*"' | head -1 | cut -d'"' -f4; } @@ -245,8 +249,19 @@ upload_fixture_cover() { echo "warning: fixture cover upload failed for $handle" >&2 } +declare -a HANDLES=( + alice.test bob.test carol.test dave.test erin.test + faye.test gideon.test hana.test ivan.test june.test + kai.test lina.test malik.test nora.test otis.test + priya.test quinn.test rosa.test sam.test theo.test +) +declare -a NEW_HANDLES=( + faye.test gideon.test hana.test ivan.test june.test + kai.test lina.test malik.test nora.test otis.test + priya.test quinn.test rosa.test sam.test theo.test +) declare -A DID JWT COVER RELEASE -for handle in alice.test bob.test carol.test dave.test erin.test; do +for handle in "${HANDLES[@]}"; do s="$(session "$handle")" DID[$handle]="$(did_of "$s")" JWT[$handle]="$(jwt_of "$s")" @@ -265,6 +280,18 @@ follow "${JWT[dave.test]}" "${DID[dave.test]}" "${DID[alice.test]}" follow "${JWT[dave.test]}" "${DID[dave.test]}" "${DID[bob.test]}" follow "${JWT[dave.test]}" "${DID[dave.test]}" "${DID[carol.test]}" follow "${JWT[dave.test]}" "${DID[dave.test]}" "${DID[erin.test]}" + +# The larger demo population forms a ring, follows alice as a common social +# anchor, and follows one collector from the original genre neighborhoods. +ORIGINAL_NEIGHBORS=(bob.test carol.test dave.test erin.test) +for i in "${!NEW_HANDLES[@]}"; do + handle="${NEW_HANDLES[$i]}" + next="${NEW_HANDLES[$(((i + 1) % ${#NEW_HANDLES[@]}))]}" + neighbor="${ORIGINAL_NEIGHBORS[$((i % ${#ORIGINAL_NEIGHBORS[@]}))]}" + follow "${JWT[$handle]}" "${DID[$handle]}" "${DID[alice.test]}" + follow "${JWT[$handle]}" "${DID[$handle]}" "${DID[$next]}" + follow "${JWT[$handle]}" "${DID[$handle]}" "${DID[$neighbor]}" +done echo "follow graph seeded" FIXTURE_COVERS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fixtures/covers" @@ -273,6 +300,13 @@ COVER[bob.test]="$(upload_fixture_cover bob.test "${JWT[bob.test]}" "$FIXTURE_CO COVER[carol.test]="$(upload_fixture_cover carol.test "${JWT[carol.test]}" "$FIXTURE_COVERS/carol.png")" COVER[dave.test]="$(upload_fixture_cover dave.test "${JWT[dave.test]}" "$FIXTURE_COVERS/dave.png")" COVER[erin.test]="$(upload_fixture_cover erin.test "${JWT[erin.test]}" "$FIXTURE_COVERS/erin.png")" +for i in "${!NEW_HANDLES[@]}"; do + handle="${NEW_HANDLES[$i]}" + COVER[$handle]="$(make_cover_blob "$handle" "${JWT[$handle]}" \ + "$(((53 * i + 70) % 190 + 40))" \ + "$(((97 * i + 30) % 190 + 40))" \ + "$(((151 * i + 10) % 190 + 40))")" +done echo "cover art seeded" # Small shared catalog: alice mints most of it, bob mints a couple, matching @@ -390,4 +424,36 @@ wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "William Basinski" "The Dis wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "Stars of the Lid" "The Tired Sounds of Stars of the Lid" ambient "${RELEASE[R14]}" wanted_entry "${JWT[erin.test]}" "${DID[erin.test]}" "Tim Hecker" "Ravedeath, 1972" ambient "${RELEASE[R15]}" +# Fifteen additional collectors make Connections, overlap, and grouped feed +# views dense enough to explore. Each owns one unique release, adopts one +# shared release, and wants another shared release. +EXTRA_ARTISTS=( + "Arooj Aftab" "Nala Sinephro" "Yusef Lateef" "Cocteau Twins" "Basic Channel" + "Nina Simone" "Fela Kuti" "Beverly Glenn-Copeland" "Talk Talk" "DJ Rashad" + "Milton Nascimento" "The Raincoats" "Ryuichi Sakamoto" "Low" "Terry Riley" +) +EXTRA_TITLES=( + "Vulture Prince" "Space 1.8" "Eastern Sounds" "Heaven or Las Vegas" "BCD" + "Pastel Blues" "Expensive Shit" "Keyboard Fantasies" "Spirit of Eden" "Double Cup" + "Clube da Esquina" "The Raincoats" "Async" "Things We Lost in the Fire" "A Rainbow in Curved Air" +) +EXTRA_GENRES=(jazz jazz jazz dream-pop techno soul afrobeat ambient art-rock footwork mpb post-punk ambient slowcore minimalism) +SHARED_OWNED=(R1 R3 R5 R6 R8 R2 R4 R6 R5 R4 R1 R3 R6 R8 R2) +SHARED_WANTED=(R6 R8 R2 R5 R3 R1 R6 R4 R8 R5 R3 R1 R5 R2 R4) + +for i in "${!NEW_HANDLES[@]}"; do + handle="${NEW_HANDLES[$i]}" + key="R$((16 + i))" + RELEASE[$key]="$(mint_release "${JWT[$handle]}" "${DID[$handle]}" \ + "${EXTRA_ARTISTS[$i]}" "${EXTRA_TITLES[$i]}" "$((1968 + i * 3))" "${EXTRA_GENRES[$i]}")" + owned_entry "${JWT[$handle]}" "${DID[$handle]}" \ + "${EXTRA_ARTISTS[$i]}" "${EXTRA_TITLES[$i]}" NM VG+ 4 "${EXTRA_GENRES[$i]}" \ + "A fixture favorite from the local demo shelf." "${COVER[$handle]}" "${RELEASE[$key]}" + owned_entry "${JWT[$handle]}" "${DID[$handle]}" \ + "Shared discovery" "Friends keep recommending this pressing" VG+ VG 4 social \ + "Added through the collector graph." "${COVER[$handle]}" "${RELEASE[${SHARED_OWNED[$i]}]}" + wanted_entry "${JWT[$handle]}" "${DID[$handle]}" \ + "Shared want" "On several nearby wantlists" social "${RELEASE[${SHARED_WANTED[$i]}]}" +done + echo "crate entries seeded" diff --git a/e2e/tests/adoption.spec.ts b/e2e/tests/adoption.spec.ts index 6f48a25..1385205 100644 --- a/e2e/tests/adoption.spec.ts +++ b/e2e/tests/adoption.spec.ts @@ -3,6 +3,7 @@ import { loginAs } from "../fixtures/login.js"; import { xrpcPath } from "../fixtures/xrpc.js"; const PASSWORD = process.env.TEST_ACCOUNT_PASSWORD || "e2e-test-password"; +const ALICE_PASSWORD = process.env.ALICE_TEST_ACCOUNT_PASSWORD || "alice"; async function addFirstSearchResult(page: Page, query: string): Promise { await page.goto("/add"); @@ -30,7 +31,7 @@ test("bob adopts alice's already-published release instead of minting a duplicat }) => { const alice = await browser.newContext(); const alicePage = await alice.newPage(); - await loginAs(alicePage, "alice.test", PASSWORD); + await loginAs(alicePage, "alice.test", ALICE_PASSWORD); await addFirstSearchResult(alicePage, "aphex twin selected ambient works"); diff --git a/e2e/tests/edit-inbox.spec.ts b/e2e/tests/edit-inbox.spec.ts index da84808..35780dd 100644 --- a/e2e/tests/edit-inbox.spec.ts +++ b/e2e/tests/edit-inbox.spec.ts @@ -3,6 +3,7 @@ import { loginAs } from "../fixtures/login.js"; import { xrpcPath } from "../fixtures/xrpc.js"; const PASSWORD = process.env.TEST_ACCOUNT_PASSWORD || "e2e-test-password"; +const ALICE_PASSWORD = process.env.ALICE_TEST_ACCOUNT_PASSWORD || "alice"; async function addFirstSearchResult(page: Page, query: string): Promise { await page.goto("/add"); @@ -38,7 +39,7 @@ test("alice proposes a correction on bob's record; bob reviews and applies it", const alice = await browser.newContext(); const alicePage = await alice.newPage(); - await loginAs(alicePage, "alice.test", PASSWORD); + await loginAs(alicePage, "alice.test", ALICE_PASSWORD); await addFirstSearchResult(alicePage, "boards of canada music has the right to children"); // Alice's copy adopted bob's canonical release, so amending it is a diff --git a/e2e/tests/oauth-error.spec.ts b/e2e/tests/oauth-error.spec.ts index c315e9c..d073eee 100644 --- a/e2e/tests/oauth-error.spec.ts +++ b/e2e/tests/oauth-error.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; -const PASSWORD = process.env.TEST_ACCOUNT_PASSWORD || "e2e-test-password"; +const ALICE_PASSWORD = process.env.ALICE_TEST_ACCOUNT_PASSWORD || "alice"; // The login error panel is driven by a stable ?error= code the server // attaches on failed OAuth redirects. The app clears the query string on @@ -40,7 +40,7 @@ test("denying authorization at the PDS lands on the error panel", async ({ await page.click("text=LOG IN WITH ATPROTO"); await page.waitForURL(/oauth\/authorize/, { timeout: 15_000 }); - await page.fill('input[type="password"]', PASSWORD); + await page.fill('input[type="password"]', ALICE_PASSWORD); await page.click('button:has-text("Sign in")'); const deny = page.locator('button:has-text("Deny")'); -- 2.51.2 From 9319d9f4a96c043730513eef3e0e395c08795b1f Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 9 Aug 2026 23:25:50 +0200 Subject: [PATCH 03/16] fix: keep the follow index in step with partial PDS delete failures --- server/src/crate_server/handlers/graph.gleam | 42 +++++++++--------- server/test/graph_handler_test.gleam | 46 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/server/src/crate_server/handlers/graph.gleam b/server/src/crate_server/handlers/graph.gleam index 1f6f5b8..23bd83f 100644 --- a/server/src/crate_server/handlers/graph.gleam +++ b/server/src/crate_server/handlers/graph.gleam @@ -77,26 +77,24 @@ fn follow_response(uri: String) -> Response { /// outage. fn pds_error(error: XrpcError, fallback: String) -> Response { case error { - core_xrpc.BadStatus(status, Some(code), _, _) - if status == 403 - || code == "InsufficientScope" - || code == "insufficient_scope" - -> - error_json( - 403, - "your session needs permission to follow collectors; sign in again to approve it", - ) + core_xrpc.BadStatus(_, Some(code), _, _) + if code == "InsufficientScope" || code == "insufficient_scope" + -> insufficient_scope_response() core_xrpc.BadStatus(status, _, _, _) if status == 403 -> - error_json( - 403, - "your session needs permission to follow collectors; sign in again to approve it", - ) + insufficient_scope_response() core_xrpc.BadStatus(status, _, _, _) if status == 401 -> error_json(401, "your session expired while writing the follow record") _ -> error_json(502, fallback) } } +fn insufficient_scope_response() -> Response { + error_json( + 403, + "your session needs permission to follow collectors; sign in again to approve it", + ) +} + fn index_follow( ctx: Context, session: OauthSession, @@ -188,17 +186,21 @@ fn delete_and_confirm( session: OauthSession, follow_uris: List(String), ) -> Response { + // Drop each edge from the index as its PDS delete confirms, rather than + // deferring cleanup to whole-batch success: a mid-batch failure must not + // leave the index claiming an edge whose record is already gone. case list.try_each(follow_uris, fn(follow_uri) { - graph_follows.delete(client, session, uri.rkey(follow_uri)) + case graph_follows.delete(client, session, uri.rkey(follow_uri)) { + Error(error) -> Error(error) + Ok(_) -> { + ctx.follow_index.edges.delete(follow_uri) + Ok(Nil) + } + } }) { Error(error) -> pds_error(error, "could not delete follow record on PDS") - Ok(_) -> { - list.each(follow_uris, fn(follow_uri) { - ctx.follow_index.edges.delete(follow_uri) - }) - wisp.json_response("{}", 200) - } + Ok(_) -> wisp.json_response("{}", 200) } } diff --git a/server/test/graph_handler_test.gleam b/server/test/graph_handler_test.gleam index 6a132c7..84086b4 100644 --- a/server/test/graph_handler_test.gleam +++ b/server/test/graph_handler_test.gleam @@ -369,6 +369,52 @@ pub fn unfollow_404s_from_the_index_when_the_edge_is_absent_test() { assert graph.unfollow(req, ctx).status == 404 } +/// Fails deleting the second (later-sorted) rkey, succeeds on the first. +fn partial_delete_failure_client() -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.deleteRecord" -> + case bit_array.to_string(req.body) { + Ok(body) -> + case string.contains(body, "3bbb") { + True -> + Error(core_xrpc.ConnectionFailed("PDS unreachable for 3bbb")) + False -> ok_response("{}") + } + Error(_) -> panic as "expected a decodable delete body" + } + _ -> panic as { "unexpected PDS call: " <> req.path } + } + }) +} + +pub fn unfollow_index_keeps_only_edges_the_pds_delete_failed_on_test() { + let assert Ok(follows) = follow_index.start() + follows.edges.upsert(follow_index.Follow( + follow_uri: follow_uri("3aaa"), + did: viewer_did, + subject: target_did, + created_at: "2026-01-01T00:00:00Z", + )) + follows.edges.upsert(follow_index.Follow( + follow_uri: follow_uri("3bbb"), + did: viewer_did, + subject: target_did, + created_at: "2026-01-02T00:00:00Z", + )) + follows.seen.mark_seen(viewer_did) + let #(ctx, cfg) = test_context_with(partial_delete_failure_client(), follows) + let req = + authed_post( + support.xrpc("graph.unfollowUser"), + json.object([#("subject", json.string(target_did))]), + cfg, + ) + assert graph.unfollow(req, ctx).status == 502 + let assert Some(edge) = follows.edges.find(viewer_did, target_did) + assert edge.follow_uri == follow_uri("3bbb") +} + pub fn deterministic_rkey_is_stable_and_atproto_safe_test() { let first = graph_follows.rkey_for_subject(target_did) let second = graph_follows.rkey_for_subject(target_did) -- 2.51.2 From 2e5b7e4857bd4151db7fc50715f57bc96a3c8808 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 9 Aug 2026 09:50:03 +0200 Subject: [PATCH 04/16] fix: align record actions and simplify sharing --- web/css/09-record.css | 30 ++++++++++++++++++++++++++++ web/src/crate_web/pages/record.gleam | 9 +++------ web/test/record_test.gleam | 6 +++--- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/web/css/09-record.css b/web/css/09-record.css index 1be5d2a..d245177 100644 --- a/web/css/09-record.css +++ b/web/css/09-record.css @@ -158,10 +158,40 @@ flex-direction: column; gap: 8px; } +.record-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} +.record-actions .btn { + width: 100%; + min-height: 64px; + border-width: 2.5px; + box-shadow: var(--shadow-md); + padding: 14px 12px; + text-align: center; + line-height: 1.15; +} .actions-footnote { margin: 0; font: 400 12px/1.4 var(--mono); font-style: italic; color: var(--ink-muted); } +.share-block { + display: flex; +} +.share-block .btn { + min-width: 144px; +} + +@media (max-width: 520px) { + .record-actions { + grid-template-columns: 1fr; + } + .record-actions .btn, + .share-block .btn { + width: 100%; + } +} diff --git a/web/src/crate_web/pages/record.gleam b/web/src/crate_web/pages/record.gleam index a967382..b141252 100644 --- a/web/src/crate_web/pages/record.gleam +++ b/web/src/crate_web/pages/record.gleam @@ -65,14 +65,11 @@ pub fn view(model: Model, detail: EntryDetail) -> Element(Msg) { /// practice, since this page requires a session). fn share_block(model: Model, entry_id: String) -> Element(Msg) { case model.auth { - LoggedIn(handle) -> + LoggedIn(_) -> html.div([attr.class("share-block")], [ - ctl.button("COPY SHARE LINK", ctl.Ghost, [ + ctl.button("COPY LINK", ctl.Ghost, [ event.on_click(CopyRecordLink(entry_id)), ]), - html.span([attr.class("share-block__hint")], [ - text(route.to_path(model.PublicRecord(handle, entry_id))), - ]), ]) LoggedOut -> element.none() } @@ -273,7 +270,7 @@ fn actions( True -> #("CONFIRM REMOVE", EntryAction(entry_id, "dropped")) False -> #("REMOVE", ArmRemove) } - html.div([attr.class("actions")], [ + html.div([attr.class("record-actions")], [ ctl.button(edit_label, ctl.Primary, [event.on_click(ToggleEdit)]), ctl.link_button( amend_label, diff --git a/web/test/record_test.gleam b/web/test/record_test.gleam index 5795f66..7f25573 100644 --- a/web/test/record_test.gleam +++ b/web/test/record_test.gleam @@ -189,10 +189,10 @@ pub fn route_change_to_record_sets_entry_detail_loading_test() { assert after.entry_detail == EntryDetailLoading } -pub fn record_view_shows_a_copy_share_link_button_with_the_public_path_test() { +pub fn record_view_shows_copy_link_without_exposing_the_public_path_test() { let html = record.view(logged_in(), a_detail()) |> element.to_string - assert string.contains(html, "COPY SHARE LINK") - assert string.contains(html, "/u/alice.test/record/e1") + assert string.contains(html, "COPY LINK") + assert !string.contains(html, "/u/alice.test/record/e1") } // The fold-out is gone: AMEND/SUGGEST A FIX is now a plain nav link to the -- 2.51.2 From 2d1531587e40b4fb652fe480b437339c26bf7111 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 9 Aug 2026 09:56:38 +0200 Subject: [PATCH 05/16] refactor: unify routed page layouts --- web/css/02-app-bar.css | 32 ++++++++++++++++++++++++++--- web/src/crate_web/route.gleam | 26 +++++++++++++++++++++++ web/src/crate_web/ui/app_bar.gleam | 16 ++++++++++++++- web/src/crate_web/ui/layout.gleam | 28 +++++++++++++++++++++++++ web/src/crate_web/view.gleam | 27 ++++++++++++------------ web/test/crate_test.gleam | 15 ++++++++++---- web/test/nav_test.gleam | 33 +++++++++++++++++++++++++++--- 7 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 web/src/crate_web/ui/layout.gleam diff --git a/web/css/02-app-bar.css b/web/css/02-app-bar.css index 5ddd344..dcc08a5 100644 --- a/web/css/02-app-bar.css +++ b/web/css/02-app-bar.css @@ -21,9 +21,9 @@ .app-bar { flex-shrink: 0; z-index: 20; - display: flex; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 34px; align-items: center; - justify-content: space-between; gap: 12px; /* 4px shorter than the masthead per the Figma spec: every remaining app-bar is a sub-page back bar. */ @@ -31,10 +31,37 @@ background: var(--bg); border-bottom: 2px solid var(--ink); } +.app-bar__side { + width: 34px; + min-width: 0; +} +.app-bar__middle { + min-width: 0; + display: flex; + justify-content: center; +} +.app-bar__breadcrumb { + min-width: 0; + display: inline-flex; + align-items: baseline; + justify-content: center; + gap: 7px; + white-space: nowrap; +} +.app-bar__brand { + font: 400 14px/1 var(--display); + letter-spacing: 0.8px; +} +.app-bar__separator { + color: var(--ink-muted); + font: 700 12px/1 var(--mono); +} .app-bar__title--sub { font: 700 13px/1 var(--mono); letter-spacing: 1px; text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; } .icon-btn { @@ -98,4 +125,3 @@ reuses the panel-in keyframe defined in 09-record.css. */ animation: panel-in var(--dur-fast) var(--ease-out-cubic); } - diff --git a/web/src/crate_web/route.gleam b/web/src/crate_web/route.gleam index 530f243..49bdcfd 100644 --- a/web/src/crate_web/route.gleam +++ b/web/src/crate_web/route.gleam @@ -7,6 +7,13 @@ import crate_web/model.{ } import gleam/uri.{type Uri} +pub type Layout { + CrateLayout + TopLevelLayout + DrillDownLayout + PublicLayout +} + pub fn parse(target: Uri) -> Route { case uri.path_segments(target.path) { ["add"] -> Add @@ -66,3 +73,22 @@ pub fn section(route: Route) -> Route { PublicCrate(_) | PublicRecord(_, _) -> route } } + +pub fn layout(route: Route) -> Layout { + case route { + Crate -> CrateLayout + Browse | Feed | Settings -> TopLevelLayout + ConnectionsFollowing + | ConnectionsFollowers + | Add + | Scan + | ScanReview + | ScanDone + | EditInbox + | EditProposalDetail(_) + | Record(_) + | RecordAmend(_) + | PressingDetail(_, _) -> DrillDownLayout + PublicCrate(_) | PublicRecord(_, _) -> PublicLayout + } +} diff --git a/web/src/crate_web/ui/app_bar.gleam b/web/src/crate_web/ui/app_bar.gleam index 5ea5f64..a58e8f4 100644 --- a/web/src/crate_web/ui/app_bar.gleam +++ b/web/src/crate_web/ui/app_bar.gleam @@ -15,13 +15,27 @@ pub fn app_bar( middle: Element(msg), right: Element(msg), ) -> Element(msg) { - html.div([attr.class("app-bar")], [left, middle, right]) + html.div([attr.class("app-bar")], [ + html.div([attr.class("app-bar__side app-bar__side--left")], [left]), + html.div([attr.class("app-bar__middle")], [middle]), + html.div([attr.class("app-bar__side app-bar__side--right")], [right]), + ]) } pub fn bar_title(label: String) -> Element(msg) { html.span([attr.class("app-bar__title--sub")], [text(label)]) } +pub fn breadcrumb_title(brand: String, label: String) -> Element(msg) { + html.span([attr.class("app-bar__breadcrumb")], [ + html.span([attr.class("app-bar__brand")], [text(brand)]), + html.span([attr.class("app-bar__separator"), attr.aria_hidden(True)], [ + text("/"), + ]), + bar_title(label), + ]) +} + pub fn icon_link(glyph: String, label: String, href: String) -> Element(msg) { html.a([attr.class("icon-btn"), attr.aria_label(label), attr.href(href)], [ text(glyph), diff --git a/web/src/crate_web/ui/layout.gleam b/web/src/crate_web/ui/layout.gleam new file mode 100644 index 0000000..7e293e6 --- /dev/null +++ b/web/src/crate_web/ui/layout.gleam @@ -0,0 +1,28 @@ +//// Shared page armatures. Routed page modules provide content; this module +//// owns the shell and chrome hierarchy around that content. + +import crate_web/ui/app_bar +import lustre/attribute as attr +import lustre/element.{type Element} +import lustre/element/html + +pub fn shell( + chrome: Element(msg), + page: Element(msg), + navigation: Element(msg), +) -> Element(msg) { + html.div([attr.class("shell")], [chrome, page, navigation]) +} + +pub fn drill_down( + brand: String, + title: String, + left: Element(msg), + right: Element(msg), + body: Element(msg), +) -> Element(msg) { + html.div([attr.class("sub-page")], [ + app_bar.app_bar(left, app_bar.breadcrumb_title(brand, title), right), + body, + ]) +} diff --git a/web/src/crate_web/view.gleam b/web/src/crate_web/view.gleam index 88c7061..d6ea319 100644 --- a/web/src/crate_web/view.gleam +++ b/web/src/crate_web/view.gleam @@ -29,6 +29,7 @@ import crate_web/pages/scan_review import crate_web/pages/settings import crate_web/route import crate_web/ui/app_bar as bar +import crate_web/ui/layout import crate_web/ui/nav import crate_web/ui/record_detail as rd import crate_web/ui/states @@ -54,16 +55,15 @@ pub fn view(model: Model) -> Element(Msg) { } fn authed_view(model: Model) -> Element(Msg) { - html.div([attr.class("shell")], [ - // Crate home folds the masthead into its own hero band instead of - // stacking a second chrome band above it (crate.gleam's `hero_bar`). - case model.route { - Crate -> element.none() - _ -> nav.masthead() + layout.shell( + case route.layout(model.route) { + route.TopLevelLayout -> nav.masthead() + route.CrateLayout | route.DrillDownLayout | route.PublicLayout -> + element.none() }, page(model), bottom_bar(model), - ]) + ) } /// Read-only shell for `/u/:handle` routes: no login gate, since the crate @@ -192,14 +192,13 @@ fn sub_page_to( right: Element(Msg), body: Element(Msg), ) -> Element(Msg) { - html.div([attr.class("sub-page")], [ - bar.app_bar( - bar.icon_button("←", "Back", Back(back_href)), - bar.bar_title(title), - right, - ), + layout.drill_down( + "CRATE", + title, + bar.icon_button("←", "Back", Back(back_href)), + right, body, - ]) + ) } fn amend_title(model: Model, entry_id: String) -> String { diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index 57f13c0..ec07a10 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -1,8 +1,8 @@ import crate/gen/shelf/list_entries import crate_web/model.{ - type Entry, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Own, OwnCrate, - Record, Rows, ShelfFailed, ShelfLoaded, ShelfLoading, Warning, blank_form, - crate_of, set_crate, + type Entry, Browse, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Own, + OwnCrate, Record, Rows, ShelfFailed, ShelfLoaded, ShelfLoading, Warning, + blank_form, crate_of, set_crate, } import crate_web/msg.{ ClearNotice, FormTitle, GotBrowseAdd, GotShelf, GotShelfMore, HandleChanged, @@ -212,7 +212,7 @@ pub fn loading_shelf_renders_skeleton_cards_matching_the_chosen_display_test() { // --- NAV1 chrome: masthead + bottom tab bar (app bar / popout retired) --- -pub fn masthead_shows_only_the_wordmark_on_hero_less_routes_test() { +pub fn drill_down_consolidates_brand_and_title_into_one_app_bar_test() { let seeded = set_crate( Model(..logged_in(), route: Record("e1")), @@ -220,6 +220,13 @@ pub fn masthead_shows_only_the_wordmark_on_hero_less_routes_test() { ShelfLoaded(Own, [an_entry()]), ) let html = view.view(seeded) |> element.to_string + assert !string.contains(html, "class=\"masthead\"") + assert string.contains(html, "class=\"app-bar__brand\">CRATE") + assert string.contains(html, "class=\"app-bar__title--sub\">RECORD") +} + +pub fn top_level_route_keeps_the_standalone_masthead_test() { + let html = view.view(Model(..logged_in(), route: Browse)) |> element.to_string assert string.contains( html, "
CRATE
", diff --git a/web/test/nav_test.gleam b/web/test/nav_test.gleam index 9887c6b..8df525e 100644 --- a/web/test/nav_test.gleam +++ b/web/test/nav_test.gleam @@ -4,9 +4,10 @@ //// an anchor. import crate_web/model.{ - Add, Browse, Crate, EditInbox, EditProposalDetail, Feed, Model, Own, OwnCrate, - PressingDetail, PressingLoading, PublicCrate, PublicRecord, Record, - RecordAmend, Scan, ScanDone, ScanReview, Settings, ShelfLoaded, set_crate, + Add, Browse, ConnectionsFollowers, ConnectionsFollowing, Crate, EditInbox, + EditProposalDetail, Feed, Model, Own, OwnCrate, PressingDetail, + PressingLoading, PublicCrate, PublicRecord, Record, RecordAmend, Scan, + ScanDone, ScanReview, Settings, ShelfLoaded, set_crate, } import crate_web/msg.{Back, OnRouteChange} import crate_web/route @@ -44,6 +45,32 @@ pub fn route_section_maps_every_drilldown_to_its_tab_test() { }) } +pub fn route_layout_classifies_every_page_armature_test() { + [ + #(Crate, route.CrateLayout), + #(Browse, route.TopLevelLayout), + #(Feed, route.TopLevelLayout), + #(Settings, route.TopLevelLayout), + #(ConnectionsFollowing, route.DrillDownLayout), + #(ConnectionsFollowers, route.DrillDownLayout), + #(Add, route.DrillDownLayout), + #(Scan, route.DrillDownLayout), + #(ScanReview, route.DrillDownLayout), + #(ScanDone, route.DrillDownLayout), + #(EditInbox, route.DrillDownLayout), + #(EditProposalDetail("uri-1"), route.DrillDownLayout), + #(Record("e1"), route.DrillDownLayout), + #(RecordAmend("e1"), route.DrillDownLayout), + #(PressingDetail("did:plc:abc", "3jz"), route.DrillDownLayout), + #(PublicCrate("alice.test"), route.PublicLayout), + #(PublicRecord("alice.test", "e1"), route.PublicLayout), + ] + |> list.each(fn(row) { + let #(route_, expected) = row + assert route.layout(route_) == expected + }) +} + pub fn crate_tab_stays_active_when_drilled_into_a_record_test() { let seeded = set_crate( -- 2.51.2 From 877e63e26cc8aaf94065ce197f5da5d176a7e743 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 9 Aug 2026 10:11:44 +0200 Subject: [PATCH 06/16] refactor: consolidate remaining page armatures --- web/src/crate_web/pages/scan_review.gleam | 4 +-- web/src/crate_web/ui/layout.gleam | 11 ++++++- web/src/crate_web/view.gleam | 39 ++++++++--------------- web/test/public_crate_test.gleam | 3 +- web/test/scan_review_test.gleam | 3 +- 5 files changed, 29 insertions(+), 31 deletions(-) diff --git a/web/src/crate_web/pages/scan_review.gleam b/web/src/crate_web/pages/scan_review.gleam index 35b6689..2f99668 100644 --- a/web/src/crate_web/pages/scan_review.gleam +++ b/web/src/crate_web/pages/scan_review.gleam @@ -3,7 +3,7 @@ import crate_web/model.{ type Model, type ScanRow, type Suggestion, Adding, Failed, Matched, - NetworkMatched, NoMatch, Scan, + NetworkMatched, NoMatch, } import crate_web/msg.{ type Msg, AcceptSuggestion, RetryBatchItem, StartBatchImport, @@ -11,7 +11,6 @@ import crate_web/msg.{ import crate_web/pages/scan_common.{ dismiss_button, item_thumb, item_title, via_handle, } -import crate_web/route import crate_web/ui/controls as ctl import crate_web/ui/covers as cov import gleam/int @@ -33,7 +32,6 @@ pub fn view(model: Model) -> Element(Msg) { fn empty_review() -> Element(Msg) { html.div([attr.class("body page-scroll")], [ html.p([attr.class("empty")], [text("Nothing scanned yet.")]), - ctl.link_button("← BACK TO SCAN", ctl.Ghost, route.to_path(Scan)), ]) } diff --git a/web/src/crate_web/ui/layout.gleam b/web/src/crate_web/ui/layout.gleam index 7e293e6..7e0534c 100644 --- a/web/src/crate_web/ui/layout.gleam +++ b/web/src/crate_web/ui/layout.gleam @@ -3,7 +3,7 @@ import crate_web/ui/app_bar import lustre/attribute as attr -import lustre/element.{type Element} +import lustre/element.{type Element, none} import lustre/element/html pub fn shell( @@ -26,3 +26,12 @@ pub fn drill_down( body, ]) } + +pub fn public_drill_down( + handle: String, + title: String, + left: Element(msg), + body: Element(msg), +) -> Element(msg) { + drill_down("@" <> handle, title, left, none(), body) +} diff --git a/web/src/crate_web/view.gleam b/web/src/crate_web/view.gleam index d6ea319..4e5a765 100644 --- a/web/src/crate_web/view.gleam +++ b/web/src/crate_web/view.gleam @@ -71,34 +71,23 @@ fn authed_view(model: Model) -> Element(Msg) { /// signed in on this browser. fn public_view(model: Model) -> Element(Msg) { case model.route { - // The record page is a back-bar drill-down: wrap it in `.sub-page` so the - // app-bar stays fixed-height chrome and only the body flexes, instead of - // both splitting the shell height (which left a tall empty band on top). - PublicRecord(_, _) -> - html.div([attr.class("shell")], [ - html.div([attr.class("sub-page")], [ - public_app_bar(model), + PublicRecord(handle, _) -> + layout.shell( + element.none(), + layout.public_drill_down( + handle, + "RECORD", + bar.icon_link( + "←", + "Back to @" <> handle, + route.to_path(PublicCrate(handle)), + ), public_page(model), - ]), - ]) - _ -> - html.div([attr.class("shell")], [ - public_app_bar(model), - public_page(model), - ]) - } -} - -fn public_app_bar(model: Model) -> Element(Msg) { - case model.route { - // Public-crate chrome is inset in its hero band (public_crate.hero). - PublicCrate(_) -> element.none() - PublicRecord(_, _) -> - bar.app_bar( - bar.icon_link("←", "Back", "/"), - bar.bar_title("RECORD"), + ), element.none(), ) + PublicCrate(_) -> + layout.shell(element.none(), public_page(model), element.none()) _ -> element.none() } } diff --git a/web/test/public_crate_test.gleam b/web/test/public_crate_test.gleam index a5dc133..fe8802a 100644 --- a/web/test/public_crate_test.gleam +++ b/web/test/public_crate_test.gleam @@ -63,7 +63,8 @@ pub fn public_record_renders_without_login_test() { ) let html = view.view(model) |> element.to_string assert string.contains(html, "Spiderland") - assert string.contains(html, "alice.test") + assert string.contains(html, "class=\"app-bar__brand\">@alice.test") + assert string.contains(html, "href=\"/u/alice.test\"") assert !string.contains(html, "LOG IN WITH ATPROTO") } diff --git a/web/test/scan_review_test.gleam b/web/test/scan_review_test.gleam index b2a3c08..ba9f4f6 100644 --- a/web/test/scan_review_test.gleam +++ b/web/test/scan_review_test.gleam @@ -89,9 +89,10 @@ pub fn cta_is_disabled_when_nothing_is_importable_test() { assert string.contains(html, "disabled") } -pub fn empty_review_prompts_back_to_scan_test() { +pub fn empty_review_relies_on_the_shared_back_armature_test() { let html = rendered([]) assert string.contains(html, "Nothing scanned yet.") + assert !string.contains(html, "BACK TO SCAN") } fn a_no_match_with_suggestions() -> List(ScanRow) { -- 2.51.2 From d33bb022a2dbeb18b18d3d9b96ad6066c10473b4 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 15:51:25 +0200 Subject: [PATCH 07/16] test: compact the connections handler and page tests --- server/test/connections_handler_test.gleam | 260 +++++++++------------ web/test/connections_test.gleam | 241 ++++++++----------- 2 files changed, 211 insertions(+), 290 deletions(-) diff --git a/server/test/connections_handler_test.gleam b/server/test/connections_handler_test.gleam index 6a2d93f..f641bfc 100644 --- a/server/test/connections_handler_test.gleam +++ b/server/test/connections_handler_test.gleam @@ -27,16 +27,6 @@ fn auth_session() -> sessions.OauthSession { sessions.OauthSession(..session, expires_at: 9_999_999_999) } -fn authed_get( - path: String, - cfg: config.Config, - session: sessions.OauthSession, -) -> wisp.Request { - let assert Ok(id) = session_store.create(cfg.sessions, session) - simulate.request(http.Get, path) - |> simulate.cookie(session_cookie, id, wisp.Signed) -} - fn context_with( client: xrpc.Client, follows: follow_index.Store, @@ -55,19 +45,22 @@ fn context_with( #(ctx, cfg) } -fn ok_response( - body: String, -) -> Result(response.Response(BitArray), xrpc.TransportError) { - Ok(response.Response(200, [], bit_array.from_string(body))) +fn respond( + follows: follow_index.Store, + client: xrpc.Client, + session: sessions.OauthSession, + query: String, +) -> wisp.Response { + let #(ctx, cfg) = context_with(client, follows) + let assert Ok(id) = session_store.create(cfg.sessions, session) + simulate.request(http.Get, support.xrpc(query)) + |> simulate.cookie(session_cookie, id, wisp.Signed) + |> connections.list_connections(ctx) } -fn list_records_client(body: String) -> xrpc.Client { - xrpc.Client(send: fn(req) { - case req.path { - "/xrpc/com.atproto.repo.listRecords" -> ok_response(body) - _ -> Error(core_xrpc.ConnectionFailed("unused")) - } - }) +fn followers_body(follows: follow_index.Store, query: String) -> String { + respond(follows, support.unreachable_client(), auth_session(), query) + |> simulate.read_body } fn item_bools(body: String, field: String) -> Result(List(Bool), Nil) { @@ -105,30 +98,38 @@ fn seed_followers( }) } -fn list_body(subjects: List(String)) -> String { - subjects - |> list.index_map(fn(subject, index) { - json.object([ - #( - "uri", - json.string( - "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/r" - <> int.to_string(index), +fn list_records_client(subjects: List(String)) -> xrpc.Client { + let body = + subjects + |> list.index_map(fn(subject, index) { + json.object([ + #( + "uri", + json.string( + "at://did:plc:me/dev.mokkenstorm.crate.graph.follow/r" + <> int.to_string(index), + ), ), - ), - #("cid", json.string("cid" <> int.to_string(index))), - #( - "value", - json.object([ - #("subject", json.string(subject)), - #("createdAt", json.string("2026-01-01T00:00:00Z")), - ]), - ), - ]) + #("cid", json.string("cid" <> int.to_string(index))), + #( + "value", + json.object([ + #("subject", json.string(subject)), + #("createdAt", json.string("2026-01-01T00:00:00Z")), + ]), + ), + ]) + }) + |> json.preprocessed_array + |> fn(records) { json.object([#("records", records)]) } + |> json.to_string + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.listRecords" -> + Ok(response.Response(200, [], bit_array.from_string(body))) + _ -> Error(core_xrpc.ConnectionFailed("unused")) + } }) - |> json.preprocessed_array - |> fn(records) { json.object([#("records", records)]) } - |> json.to_string } pub fn unauthenticated_connections_are_rejected_test() { @@ -142,86 +143,65 @@ pub fn unauthenticated_connections_are_rejected_test() { assert connections.list_connections(req, ctx).status == 401 } -pub fn followers_do_not_require_the_viewers_pds_test() { - let follows = support.fresh_follow_index() - seed_followers(follows, "did:plc:me", ["did:plc:follower"]) - follows.seen.mark_seen("did:plc:me") - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=followers"), - cfg, - auth_session(), - ) - let resp = connections.list_connections(req, ctx) - assert resp.status == 200 - let body = simulate.read_body(resp) - assert support.field_nested(body, ["items"], ["did"]) - == Ok(["did:plc:follower"]) - assert support.field_bool(body, ["relationshipComplete"]) == Ok(True) +// Followers are served from the index alone, so an unreachable PDS is fine; +// only `relationshipComplete` depends on having seen the viewer's own follows. +pub fn followers_never_need_the_viewers_pds_test() { + [#(True, True), #(False, False)] + |> list.each(fn(row) { + let #(viewer_seen, relationship_complete) = row + let follows = support.fresh_follow_index() + seed_followers(follows, "did:plc:me", ["did:plc:follower"]) + case viewer_seen { + True -> follows.seen.mark_seen("did:plc:me") + False -> Nil + } + let body = + followers_body(follows, "graph.listConnections?direction=followers") + assert support.field_nested(body, ["items"], ["did"]) + == Ok(["did:plc:follower"]) + assert support.field_bool(body, ["relationshipComplete"]) + == Ok(relationship_complete) + assert item_bools(body, "viewerFollows") == Ok([False]) + }) } -pub fn cold_followers_report_incomplete_relationships_test() { +pub fn an_unresolvable_identity_falls_back_to_the_did_test() { let follows = support.fresh_follow_index() - seed_followers(follows, "did:plc:me", ["did:plc:follower"]) - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=followers"), - cfg, - auth_session(), - ) - let body = simulate.read_body(connections.list_connections(req, ctx)) - assert support.field_bool(body, ["relationshipComplete"]) == Ok(False) - assert item_bools(body, "viewerFollows") == Ok([False]) + seed_followers(follows, "did:plc:me", ["did:plc:unresolvable"]) + follows.seen.mark_seen("did:plc:me") + let body = + followers_body(follows, "graph.listConnections?direction=followers") + assert support.field_nested(body, ["items"], ["handle"]) + == Ok(["did:plc:unresolvable"]) } -pub fn following_pds_failure_is_upstream_failure_test() { +// Following reads through to the PDS, so its failure is upstream failure +// rather than a silent fall back to whatever the index still remembers. +pub fn following_reports_a_pds_failure_and_keeps_the_index_test() { let follows = support.fresh_follow_index() follows.edges.upsert(follower_edge("stale", "did:plc:me", "did:plc:stale")) follows.seen.mark_seen("did:plc:me") - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=following"), - cfg, + let resp = + respond( + follows, + support.unreachable_client(), auth_session(), + "graph.listConnections?direction=following", ) - assert connections.list_connections(req, ctx).status == 502 + assert resp.status == 502 assert follows.edges.following("did:plc:me") == ["did:plc:stale"] } -pub fn identity_failure_falls_back_to_the_did_test() { - let follows = support.fresh_follow_index() - seed_followers(follows, "did:plc:me", ["did:plc:unresolvable"]) - follows.seen.mark_seen("did:plc:me") - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=followers"), - cfg, - auth_session(), - ) - let body = simulate.read_body(connections.list_connections(req, ctx)) - assert support.field_nested(body, ["items"], ["handle"]) - == Ok(["did:plc:unresolvable"]) -} - -pub fn following_uses_pds_data_and_marks_the_viewer_complete_test() { +pub fn following_replaces_the_index_with_pds_data_test() { let follows = support.fresh_follow_index() follows.edges.upsert(follower_edge("stale", "did:plc:me", "did:plc:stale")) - let #(ctx, cfg) = - context_with( - list_records_client(list_body(["did:plc:first", "did:plc:second"])), + let resp = + respond( follows, - ) - let req = - authed_get( - support.xrpc("graph.listConnections?direction=following&limit=1"), - cfg, + list_records_client(["did:plc:first", "did:plc:second"]), auth_session(), + "graph.listConnections?direction=following&limit=1", ) - let resp = connections.list_connections(req, ctx) assert resp.status == 200 let body = simulate.read_body(resp) assert support.field_nested(body, ["items"], ["did"]) == Ok(["did:plc:first"]) @@ -229,52 +209,38 @@ pub fn following_uses_pds_data_and_marks_the_viewer_complete_test() { assert support.field_bool(body, ["relationshipComplete"]) == Ok(False) assert support.field_present(body, ["cursor"]) assert follows.edges.following("did:plc:me") - == [ - "did:plc:first", - "did:plc:second", - ] + == ["did:plc:first", "did:plc:second"] } -pub fn connection_cursor_paginates_and_is_viewer_scoped_test() { +pub fn a_connection_cursor_paginates_and_is_viewer_scoped_test() { let follows = support.fresh_follow_index() seed_followers(follows, "did:plc:me", ["did:plc:a", "did:plc:b"]) - follows.seen.mark_seen("did:plc:me") - let #(ctx, cfg) = context_with(support.unreachable_client(), follows) - let first_req = - authed_get( - support.xrpc("graph.listConnections?direction=followers&limit=1"), - cfg, - auth_session(), - ) - let first_body = - simulate.read_body(connections.list_connections(first_req, ctx)) - let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) - let next_req = - authed_get( - support.xrpc( - "graph.listConnections?direction=followers&limit=1&cursor=" <> cursor, - ), - cfg, - auth_session(), - ) - let next_body = - simulate.read_body(connections.list_connections(next_req, ctx)) - assert support.field_nested(next_body, ["items"], ["did"]) - == Ok(["did:plc:b"]) - - let other = sessions.OauthSession(..auth_session(), did: "did:plc:other") seed_followers(follows, "did:plc:other", ["did:plc:c"]) + follows.seen.mark_seen("did:plc:me") follows.seen.mark_seen("did:plc:other") - let other_req = - authed_get( - support.xrpc( - "graph.listConnections?direction=followers&limit=1&cursor=" <> cursor, - ), - cfg, - other, - ) - let other_body = - simulate.read_body(connections.list_connections(other_req, ctx)) - assert support.field_nested(other_body, ["items"], ["did"]) - == Ok(["did:plc:c"]) + let first = + followers_body(follows, "graph.listConnections?direction=followers&limit=1") + let assert Ok(cursor) = support.field_string(first, ["cursor"]) + let paged = + "graph.listConnections?direction=followers&limit=1&cursor=" <> cursor + + // The same cursor handed to a different viewer resumes in that viewer's + // own slice rather than leaking this one's. + [ + #(auth_session(), ["did:plc:b"]), + #(sessions.OauthSession(..auth_session(), did: "did:plc:other"), [ + "did:plc:c", + ]), + ] + |> list.each(fn(row) { + let #(session, expected) = row + let body = + simulate.read_body(respond( + follows, + support.unreachable_client(), + session, + paged, + )) + assert support.field_nested(body, ["items"], ["did"]) == Ok(expected) + }) } diff --git a/web/test/connections_test.gleam b/web/test/connections_test.gleam index e0ab1ba..f67f751 100644 --- a/web/test/connections_test.gleam +++ b/web/test/connections_test.gleam @@ -11,80 +11,93 @@ import crate_web/msg.{ import crate_web/pages/connections import crate_web/route import crate_web/update.{update} +import gleam/list import gleam/option.{None, Some} import gleam/string import gleam/uri import lustre/element import support.{empty_effect, logged_in, network_error} +fn connection(handle: String) -> Connection { + Connection( + did: "did:" <> handle, + handle: handle <> ".test", + viewer_follows: False, + follows_viewer: False, + mutual: False, + ) +} + +fn view_html(state: model.ConnectionsState, followers_tab: Bool) -> String { + connections.view( + model.Model(..logged_in(), connections: state), + followers_tab, + ) + |> element.to_string +} + +fn loaded_follow_state(follows: Bool) -> Model { + Model( + ..logged_in(), + route: ConnectionsFollowing, + connections: ConnectionsLoaded( + [Connection(..connection("bob"), viewer_follows: follows)], + None, + True, + True, + ), + ) +} + +fn connection_follows(model: Model) -> Bool { + case model.connections { + ConnectionsLoaded([first, ..], _, _, _) -> first.viewer_follows + _ -> False + } +} + pub fn connections_routes_round_trip_test() { assert route.to_path(ConnectionsFollowers) == "/connections/followers" let assert Ok(target) = uri.parse("/connections/followers") assert route.parse(target) == ConnectionsFollowers let assert Ok(default) = uri.parse("/connections") - assert route.parse(default) == model.ConnectionsFollowing + assert route.parse(default) == ConnectionsFollowing } -pub fn followers_empty_state_explains_index_incompleteness_test() { - let html = - connections.view( - model.Model( - ..logged_in(), - connections: ConnectionsLoaded([], None, False, True), - ), +// An empty follower list is ambiguous (nobody follows you vs the index has +// not caught up), so the empty state must not claim the former. +pub fn connection_states_render_distinctly_test() { + [ + #( + ConnectionsLoaded([], None, False, True), True, - ) - |> element.to_string - assert string.contains(html, "No indexed followers yet") - assert string.contains(html, "appview may still be catching up") - assert !string.contains(html, "No one follows you yet") -} - -pub fn connection_row_exposes_mutual_and_accessible_follow_action_test() { - let html = - connections.view( - model.Model( - ..logged_in(), - connections: ConnectionsLoaded( - [ - Connection( - did: "did:plc:bob", - handle: "bob.test", - viewer_follows: False, - follows_viewer: True, - mutual: False, - ), - ], - None, - False, - True, - ), + ["No indexed followers yet", "appview may still be catching up"], + ["No one follows you yet"], + ), + #( + ConnectionsLoaded( + [Connection(..connection("bob"), follows_viewer: True)], + None, + False, + True, ), True, - ) - |> element.to_string - assert string.contains(html, "@bob.test") - assert string.contains(html, "Follow @bob.test") - assert string.contains(html, "FOLLOW") - assert string.contains(html, "aria-current=\"page\"") -} - -pub fn connection_loading_and_failure_states_are_distinct_test() { - let loading = - connections.view( - model.Model(..logged_in(), connections: ConnectionsLoading), - False, - ) - |> element.to_string - let failed = - connections.view( - model.Model(..logged_in(), connections: ConnectionsFailed), - False, - ) - |> element.to_string - assert string.contains(loading, "LOADING CONNECTIONS") - assert string.contains(failed, "RETRY") - assert !string.contains(loading, "RETRY") + ["@bob.test", "Follow @bob.test", "FOLLOW", "aria-current=\"page\""], + [], + ), + #(ConnectionsLoading, False, ["LOADING CONNECTIONS"], ["RETRY"]), + #(ConnectionsFailed, False, ["RETRY"], []), + ] + |> list.each(fn(row) { + let #(state, followers_tab, present, absent) = row + let html = view_html(state, followers_tab) + list.each(present, fn(needle) { + assert string.contains(html, needle) + }) + list.each(absent, fn(needle) { + assert !string.contains(html, needle) + }) + }) } pub fn stale_same_route_list_response_is_ignored_after_retry_test() { @@ -163,36 +176,35 @@ pub fn pagination_response_appends_only_for_current_generation_test() { assert loaded.connections_loading_more == False } -pub fn optimistic_follow_success_clears_pending_write_test() { - let seeded = loaded_follow_state(False) - let #(optimistic, _) = - update(seeded, ToggleConnectionFollow("did:bob", False)) - let assert Some(request) = optimistic.connection_pending - assert request.did == "did:bob" - assert request.desired == True - assert connection_follows(optimistic) == True - let #(confirmed, _) = - update(optimistic, GotConnectionFollow(request, Ok("at://alice/follow/1"))) - assert confirmed.connection_pending == None - assert connection_follows(confirmed) == True -} - -pub fn optimistic_follow_error_rolls_back_test() { - let seeded = loaded_follow_state(False) - let #(optimistic, _) = - update(seeded, ToggleConnectionFollow("did:bob", False)) - let assert Some(request) = optimistic.connection_pending - let #(rolled_back, _) = - update(optimistic, GotConnectionFollow(request, Error(network_error()))) - assert rolled_back.connection_pending == None - assert connection_follows(rolled_back) == False - assert rolled_back.notice != None +// The row flips before the write lands; the response either confirms it or +// rolls it back with a notice. +pub fn an_optimistic_follow_is_confirmed_or_rolled_back_test() { + [ + #(Ok("at://alice/follow/1"), True, False), + #(Error(network_error()), False, True), + ] + |> list.each(fn(row) { + let #(outcome, follows_after, notice_expected) = row + let #(optimistic, _) = + update( + loaded_follow_state(False), + ToggleConnectionFollow("did:bob", False), + ) + let assert Some(request) = optimistic.connection_pending + assert request.did == "did:bob" + assert request.desired == True + assert connection_follows(optimistic) == True + let #(settled, _) = + update(optimistic, GotConnectionFollow(request, outcome)) + assert settled.connection_pending == None + assert connection_follows(settled) == follows_after + assert { settled.notice != None } == notice_expected + }) } pub fn stale_follow_completion_after_same_route_reload_is_ignored_test() { - let seeded = loaded_follow_state(False) let #(optimistic, _) = - update(seeded, ToggleConnectionFollow("did:bob", False)) + update(loaded_follow_state(False), ToggleConnectionFollow("did:bob", False)) let assert Some(request) = optimistic.connection_pending let #(reloaded, _) = update(optimistic, OnRouteChange(ConnectionsFollowing)) assert reloaded.connection_pending == None @@ -202,66 +214,9 @@ pub fn stale_follow_completion_after_same_route_reload_is_ignored_test() { assert after_stale.connection_pending == None } -fn connection(handle: String) -> Connection { - Connection( - did: "did:" <> handle, - handle: handle <> ".test", - viewer_follows: False, - follows_viewer: False, - mutual: False, - ) -} - -fn loaded_follow_state(follows: Bool) -> Model { - Model( - ..logged_in(), - route: ConnectionsFollowing, - connections: ConnectionsLoaded( - [ - Connection( - did: "did:bob", - handle: "bob.test", - viewer_follows: follows, - follows_viewer: False, - mutual: False, - ), - ], - None, - True, - True, - ), - ) -} - -fn connection_follows(model: Model) -> Bool { - case model.connections { - ConnectionsLoaded([first, ..], _, _, _) -> first.viewer_follows - _ -> False - } -} - pub fn missing_follow_permission_offers_reauthorization_in_connections_test() { - let did = "did:plc:bob" - let model = - model.Model( - ..logged_in(), - route: ConnectionsFollowing, - connections: ConnectionsLoaded( - [ - Connection( - did:, - handle: "bob.test", - viewer_follows: False, - follows_viewer: False, - mutual: False, - ), - ], - None, - True, - True, - ), - ) - let #(pending, effect) = update(model, ToggleConnectionFollow(did, False)) + let #(pending, effect) = + update(loaded_follow_state(False), ToggleConnectionFollow("did:bob", False)) assert effect != empty_effect() let assert Some(request) = pending.connection_pending let #(updated, _) = -- 2.51.2 From bc29b5d1c1201bbb4d09003f553b637879c07288 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 16:56:32 +0200 Subject: [PATCH 08/16] test: compact the graph and feed handler tests --- server/test/feed_handler_test.gleam | 398 +++++++-------------- server/test/graph_handler_test.gleam | 500 +++++++++++---------------- 2 files changed, 331 insertions(+), 567 deletions(-) diff --git a/server/test/feed_handler_test.gleam b/server/test/feed_handler_test.gleam index 32662df..7ec8250 100644 --- a/server/test/feed_handler_test.gleam +++ b/server/test/feed_handler_test.gleam @@ -30,8 +30,6 @@ const viewer_did = "did:plc:x" const session_cookie = "ar_oauth_sid" -const far_future = 9_999_999_999 - fn feed_path() -> String { support.xrpc("feed.getFeedSkeleton") } @@ -41,12 +39,12 @@ fn a_session_as(did: String) -> sessions.OauthSession { support.stub_session_with( access_token: "at", refresh_token: "rt", - expires_at: far_future, + expires_at: 9_999_999_999, ) sessions.OauthSession(..session, did:) } -/// Always an "acquired" row -- no test in this file varies the status. +// Always an "acquired" row: no test in this file varies the status. fn adoption( did did: String, rkey rkey: String, @@ -67,14 +65,31 @@ fn release_uri(rkey: String) -> String { "at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/" <> rkey } -/// The nth day since the epoch, RFC3339 -- a cheap way to generate rows -/// spaced far enough apart (24h) that none of them ever collapse together. +// The nth day since the epoch, spacing rows far enough apart (24h) that none +// of them ever collapse together. fn nth_day(n: Int) -> String { timestamp.from_unix_seconds(0) |> timestamp.add(duration.hours(24 * n)) |> timestamp.to_rfc3339(calendar.utc_offset) } +// Each row is #(adopter did, day), newest first in the feed's own ordering. +fn store_with(rows: List(#(String, Int))) -> catalog_index.Store { + let assert Ok(store) = catalog_index.start() + rows + |> list.index_map(fn(row, i) { + let #(did, day) = row + let n = int.to_string(i + 1) + store.adoptions.upsert(adoption( + did:, + rkey: "e" <> n, + release_uri: release_uri("r" <> n), + created_at: nth_day(day), + )) + }) + store +} + fn follow_record_json(rkey: String, subject: String) -> json.Json { json.object([ #( @@ -94,21 +109,42 @@ fn follow_record_json(rkey: String, subject: String) -> json.Json { ]) } -fn list_records_body(records: List(json.Json)) -> String { - json.object([#("records", json.preprocessed_array(records))]) - |> json.to_string -} - -fn network_client(follows_body: String) -> xrpc.Client { +fn follows_client(subjects: List(String)) -> xrpc.Client { + let body = + subjects + |> list.index_map(fn(subject, i) { + follow_record_json("3f" <> int.to_string(i), subject) + }) + |> fn(records) { + json.object([#("records", json.preprocessed_array(records))]) + |> json.to_string + } xrpc.Client(send: fn(req) { case req.path { "/xrpc/com.atproto.repo.listRecords" -> - Ok(response.Response(200, [], bit_array.from_string(follows_body))) + Ok(response.Response(200, [], bit_array.from_string(body))) _ -> panic as { "unexpected path: " <> req.path } } }) } +// Any PDS call at all blows up: the assertion for a warm feed request. +fn panicking_client() -> xrpc.Client { + xrpc.Client(send: fn(req) { panic as { "unexpected PDS call: " <> req.path } }) +} + +fn indexed_follow(rkey: String, subject: String) -> follow_index.Follow { + follow_index.Follow( + follow_uri: "at://" + <> viewer_did + <> "/dev.mokkenstorm.crate.graph.follow/" + <> rkey, + did: viewer_did, + subject:, + created_at: "2026-01-01T00:00:00Z", + ) +} + fn test_context( client: xrpc.Client, index: catalog_index.Store, @@ -127,20 +163,22 @@ fn test_context( #(ctx, cfg, ctx.follow_index) } -fn authed_get(path: String, cfg: config.Config) -> wisp.Request { - authed_get_as(path, cfg, viewer_did) -} - fn authed_get_as( - path: String, + query: String, cfg: config.Config, did: String, ) -> wisp.Request { let assert Ok(id) = session_store.create(cfg.sessions, a_session_as(did)) - simulate.request(http.Get, path) + simulate.request(http.Get, feed_path() <> query) |> simulate.cookie(session_cookie, id, wisp.Signed) } +fn get_feed(ctx: Context, cfg: config.Config, query: String) -> String { + let resp = feed.get_feed_skeleton(authed_get_as(query, cfg, viewer_did), ctx) + assert resp.status == 200 + simulate.read_body(resp) +} + fn item_actors(body: String) -> List(String) { let decoder = decode.at( @@ -161,298 +199,104 @@ fn item_count(body: String) -> Int { } pub fn followed_rows_are_included_and_viewer_and_non_followed_are_excluded_test() { - let assert Ok(store) = catalog_index.start() - store.adoptions.upsert(adoption( - did: "did:plc:f1", - rkey: "e1", - release_uri: release_uri("r1"), - created_at: nth_day(3), - )) - store.adoptions.upsert(adoption( - did: "did:plc:n1", - rkey: "e2", - release_uri: release_uri("r2"), - created_at: nth_day(2), - )) - store.adoptions.upsert(adoption( - did: viewer_did, - rkey: "e3", - release_uri: release_uri("r3"), - created_at: nth_day(1), - )) - let client = - network_client( - list_records_body([follow_record_json("3aaa", "did:plc:f1")]), - ) - let #(ctx, cfg, follows) = test_context(client, store) - follows.edges.upsert(follow_index.Follow( - follow_uri: "at://" - <> viewer_did - <> "/dev.mokkenstorm.crate.graph.follow/stale", - did: viewer_did, - subject: "did:plc:stale", - created_at: "2026-01-01T00:00:00Z", - )) - let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) - assert resp.status == 200 - let body = simulate.read_body(resp) + let store = + store_with([#("did:plc:f1", 3), #("did:plc:n1", 2), #(viewer_did, 1)]) + let #(ctx, cfg, follows) = test_context(follows_client(["did:plc:f1"]), store) + follows.edges.upsert(indexed_follow("stale", "did:plc:stale")) + let body = get_feed(ctx, cfg, "") assert support.field_bool(body, ["fallback"]) == Ok(False) assert item_actors(body) == ["did:plc:f1"] assert follows.edges.following(viewer_did) == ["did:plc:f1"] } -pub fn empty_follow_graph_falls_back_to_network_wide_including_the_viewer_test() { - let assert Ok(store) = catalog_index.start() - store.adoptions.upsert(adoption( - did: viewer_did, - rkey: "e1", - release_uri: release_uri("r1"), - created_at: nth_day(2), - )) - store.adoptions.upsert(adoption( - did: "did:plc:n1", - rkey: "e2", - release_uri: release_uri("r2"), - created_at: nth_day(1), - )) - let client = network_client(list_records_body([])) - let #(ctx, cfg, _follows) = test_context(client, store) - let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) - assert resp.status == 200 - let body = simulate.read_body(resp) - assert support.field_bool(body, ["fallback"]) == Ok(True) - let actors = item_actors(body) - assert list.contains(actors, viewer_did) - assert list.contains(actors, "did:plc:n1") -} - -pub fn nonempty_follows_with_no_followed_adoptions_falls_back_on_the_first_page_test() { - let assert Ok(store) = catalog_index.start() - store.adoptions.upsert(adoption( - did: "did:plc:n1", - rkey: "e1", - release_uri: release_uri("r1"), - created_at: nth_day(1), - )) - // Followed, but they have no adoptions of their own. - let client = - network_client( - list_records_body([follow_record_json("3bbb", "did:plc:f1")]), - ) - let #(ctx, cfg, _follows) = test_context(client, store) - let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) - assert resp.status == 200 - let body = simulate.read_body(resp) - assert support.field_bool(body, ["fallback"]) == Ok(True) - assert item_actors(body) == ["did:plc:n1"] -} - -pub fn network_fallback_cursor_continues_in_network_mode_test() { - let assert Ok(store) = catalog_index.start() - store.adoptions.upsert(adoption( - did: "did:plc:n1", - rkey: "e1", - release_uri: release_uri("r1"), - created_at: nth_day(3), - )) - store.adoptions.upsert(adoption( - did: "did:plc:n2", - rkey: "e2", - release_uri: release_uri("r2"), - created_at: nth_day(2), - )) - let client = - network_client( - list_records_body([follow_record_json("3bbb", "did:plc:f1")]), - ) - let #(ctx, cfg, _follows) = test_context(client, store) - let first = - feed.get_feed_skeleton(authed_get(feed_path() <> "?limit=1", cfg), ctx) - let first_body = simulate.read_body(first) - assert support.field_bool(first_body, ["fallback"]) == Ok(True) - assert item_actors(first_body) == ["did:plc:n1"] - let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) - let second = - feed.get_feed_skeleton( - authed_get(feed_path() <> "?limit=1&cursor=" <> cursor, cfg), - ctx, - ) - let second_body = simulate.read_body(second) - assert support.field_bool(second_body, ["fallback"]) == Ok(True) - assert item_actors(second_body) == ["did:plc:n2"] - assert support.field_present(second_body, ["cursor"]) == False +// Nothing to show from the follow graph flips the first page to network-wide, +// whether the graph is empty or its members simply have no adoptions. +pub fn a_first_page_with_no_followed_rows_falls_back_to_network_wide_test() { + [#([], [viewer_did, "did:plc:n1"]), #(["did:plc:f1"], ["did:plc:n1"])] + |> list.each(fn(row) { + let #(follows, expected_actors) = row + let store = + store_with(list.index_map(expected_actors, fn(did, i) { #(did, 2 - i) })) + let #(ctx, cfg, _follows) = test_context(follows_client(follows), store) + let body = get_feed(ctx, cfg, "") + assert support.field_bool(body, ["fallback"]) == Ok(True) + assert item_actors(body) == expected_actors + }) } -pub fn network_fallback_cursor_is_scoped_to_the_viewer_test() { - let assert Ok(store) = catalog_index.start() - store.adoptions.upsert(adoption( - did: "did:plc:n1", - rkey: "e1", - release_uri: release_uri("r1"), - created_at: nth_day(3), - )) - store.adoptions.upsert(adoption( - did: "did:plc:n2", - rkey: "e2", - release_uri: release_uri("r2"), - created_at: nth_day(2), - )) - let client = - network_client( - list_records_body([follow_record_json("3bbb", "did:plc:f1")]), - ) - let #(ctx, cfg, _follows) = test_context(client, store) - let first = - feed.get_feed_skeleton(authed_get(feed_path() <> "?limit=1", cfg), ctx) - let first_body = simulate.read_body(first) - let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) - let second = - feed.get_feed_skeleton( - authed_get_as( - feed_path() <> "?limit=1&cursor=" <> cursor, - cfg, - "did:plc:other", - ), - ctx, - ) - let second_body = simulate.read_body(second) - assert support.field_bool(second_body, ["fallback"]) == Ok(True) - assert item_actors(second_body) == ["did:plc:n1"] +// The namespace a cursor carries is what keeps followed and network pages from +// crossing: a cursor minted in one mode resumes in that same mode. +pub fn a_cursor_resumes_in_the_mode_that_minted_it_test() { + [ + #(["did:plc:f1"], ["did:plc:n1", "did:plc:n2"], True), + #(["did:plc:f1", "did:plc:f2"], ["did:plc:f1", "did:plc:f2"], False), + ] + |> list.each(fn(row) { + let #(follows, actors, fallback) = row + let store = store_with(list.index_map(actors, fn(did, i) { #(did, 3 - i) })) + let #(ctx, cfg, _follows) = test_context(follows_client(follows), store) + let first = get_feed(ctx, cfg, "?limit=1") + assert support.field_bool(first, ["fallback"]) == Ok(fallback) + assert item_actors(first) == list.take(actors, 1) + let assert Ok(cursor) = support.field_string(first, ["cursor"]) + let second = get_feed(ctx, cfg, "?limit=1&cursor=" <> cursor) + assert support.field_bool(second, ["fallback"]) == Ok(fallback) + assert item_actors(second) == list.drop(actors, 1) + assert support.field_present(second, ["cursor"]) == False + }) } -pub fn cursor_stays_in_the_followed_namespace_across_pages_test() { - let assert Ok(store) = catalog_index.start() - store.adoptions.upsert(adoption( - did: "did:plc:f1", - rkey: "e1", - release_uri: release_uri("r1"), - created_at: nth_day(3), - )) - store.adoptions.upsert(adoption( - did: "did:plc:f2", - rkey: "e2", - release_uri: release_uri("r2"), - created_at: nth_day(2), - )) - store.adoptions.upsert(adoption( - did: "did:plc:f3", - rkey: "e3", - release_uri: release_uri("r3"), - created_at: nth_day(1), - )) - let client = - network_client( - list_records_body([ - follow_record_json("3aaa", "did:plc:f1"), - follow_record_json("3bbb", "did:plc:f2"), - follow_record_json("3ccc", "did:plc:f3"), - ]), - ) - let #(ctx, cfg, _follows) = test_context(client, store) - let first = - feed.get_feed_skeleton(authed_get(feed_path() <> "?limit=1", cfg), ctx) - let first_body = simulate.read_body(first) - assert support.field_bool(first_body, ["fallback"]) == Ok(False) - assert item_actors(first_body) == ["did:plc:f1"] - let assert Ok(cursor) = support.field_string(first_body, ["cursor"]) +pub fn a_network_fallback_cursor_is_scoped_to_the_viewer_test() { + let store = store_with([#("did:plc:n1", 3), #("did:plc:n2", 2)]) + let #(ctx, cfg, _follows) = + test_context(follows_client(["did:plc:f1"]), store) + let first = get_feed(ctx, cfg, "?limit=1") + let assert Ok(cursor) = support.field_string(first, ["cursor"]) let second = feed.get_feed_skeleton( - authed_get(feed_path() <> "?limit=1&cursor=" <> cursor, cfg), + authed_get_as("?limit=1&cursor=" <> cursor, cfg, "did:plc:other"), ctx, ) - let second_body = simulate.read_body(second) - assert support.field_bool(second_body, ["fallback"]) == Ok(False) - assert item_actors(second_body) == ["did:plc:f2"] + |> simulate.read_body + assert support.field_bool(second, ["fallback"]) == Ok(True) + assert item_actors(second) == ["did:plc:n1"] } pub fn limit_is_capped_at_the_lexicon_default_test() { - let assert Ok(store) = catalog_index.start() - list.repeat(0, 105) - |> list.index_map(fn(_, i) { i + 1 }) - |> list.each(fn(n) { - store.adoptions.upsert(adoption( - did: "did:plc:f1", - rkey: "e" <> int.to_string(n), - release_uri: release_uri("r" <> int.to_string(n)), - created_at: nth_day(n), - )) - }) - let client = - network_client( - list_records_body([follow_record_json("3aaa", "did:plc:f1")]), + let store = + store_with( + list.index_map(list.repeat("did:plc:f1", 105), fn(did, i) { + #(did, i + 1) + }), ) - let #(ctx, cfg, _follows) = test_context(client, store) - let resp = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) - let body = simulate.read_body(resp) + let #(ctx, cfg, _follows) = + test_context(follows_client(["did:plc:f1"]), store) + let body = get_feed(ctx, cfg, "") assert item_count(body) == 100 assert support.field_present(body, ["cursor"]) } -/// Any PDS call at all blows up: the assertion for a warm feed request. -fn panicking_client() -> xrpc.Client { - xrpc.Client(send: fn(req) { panic as { "unexpected PDS call: " <> req.path } }) -} - -fn indexed_follow(rkey: String, subject: String) -> follow_index.Follow { - follow_index.Follow( - follow_uri: "at://" - <> viewer_did - <> "/dev.mokkenstorm.crate.graph.follow/" - <> rkey, - did: viewer_did, - subject:, - created_at: "2026-01-01T00:00:00Z", - ) -} - pub fn an_indexed_follow_graph_is_served_without_touching_the_pds_test() { - let assert Ok(store) = catalog_index.start() - store.adoptions.upsert(adoption( - did: "did:plc:f1", - rkey: "e1", - release_uri: release_uri("r1"), - created_at: nth_day(3), - )) - store.adoptions.upsert(adoption( - did: "did:plc:n1", - rkey: "e2", - release_uri: release_uri("r2"), - created_at: nth_day(2), - )) + let store = store_with([#("did:plc:f1", 3), #("did:plc:n1", 2)]) let #(ctx, cfg, follows) = test_context(panicking_client(), store) follows.edges.upsert(indexed_follow("3aaa", "did:plc:f1")) follows.seen.mark_seen(viewer_did) - let res = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) - assert res.status == 200 - assert item_actors(simulate.read_body(res)) == ["did:plc:f1"] + assert item_actors(get_feed(ctx, cfg, "")) == ["did:plc:f1"] } pub fn a_never_seen_viewer_is_seeded_from_their_pds_once_test() { - let assert Ok(store) = catalog_index.start() - store.adoptions.upsert(adoption( - did: "did:plc:f1", - rkey: "e1", - release_uri: release_uri("r1"), - created_at: nth_day(3), - )) - let client = - network_client( - list_records_body([follow_record_json("3aaa", "did:plc:f1")]), - ) - let #(ctx, cfg, follows) = test_context(client, store) + let store = store_with([#("did:plc:f1", 3)]) + let #(ctx, cfg, follows) = test_context(follows_client(["did:plc:f1"]), store) assert follows.seen.has_seen(viewer_did) == False - let res = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) - assert res.status == 200 - assert item_actors(simulate.read_body(res)) == ["did:plc:f1"] + assert item_actors(get_feed(ctx, cfg, "")) == ["did:plc:f1"] // Seeded and marked, so the next request never asks the PDS again. assert follows.edges.following(viewer_did) == ["did:plc:f1"] assert follows.seen.has_seen(viewer_did) == True } pub fn a_failed_seed_leaves_the_viewer_unmarked_for_a_retry_test() { - let assert Ok(store) = catalog_index.start() - let #(ctx, cfg, follows) = test_context(support.unreachable_client(), store) - let res = feed.get_feed_skeleton(authed_get(feed_path(), cfg), ctx) - assert res.status == 200 + let #(ctx, cfg, follows) = + test_context(support.unreachable_client(), store_with([])) + let _ = get_feed(ctx, cfg, "") assert follows.seen.has_seen(viewer_did) == False } diff --git a/server/test/graph_handler_test.gleam b/server/test/graph_handler_test.gleam index 84086b4..e76b7fd 100644 --- a/server/test/graph_handler_test.gleam +++ b/server/test/graph_handler_test.gleam @@ -27,52 +27,34 @@ import wisp/simulate const target_did = "did:plc:target" -const session_cookie = "ar_oauth_sid" +const viewer_did = "did:plc:x" -const far_future = 9_999_999_999 +const session_cookie = "ar_oauth_sid" fn a_session() -> sessions.OauthSession { support.stub_session_with( access_token: "at", refresh_token: "rt", - expires_at: far_future, + expires_at: 9_999_999_999, ) } +fn follow_uri(rkey: String) -> String { + "at://" <> viewer_did <> "/dev.mokkenstorm.crate.graph.follow/" <> rkey +} + fn stored_follow(rkey: String, subject: String) -> StoredItem(GraphFollow) { StoredItem( - uri: "at://did:plc:x/dev.mokkenstorm.crate.graph.follow/" <> rkey, + uri: follow_uri(rkey), cid: "bafy" <> rkey, rkey:, value: GraphFollow(created_at: "2026-01-01T00:00:00Z", subject:), ) } -pub fn find_locates_the_stored_follow_or_returns_none_test() { - let stored = [ - stored_follow("3aaa", "did:plc:other"), - stored_follow("3bbb", target_did), - ] - let assert Some(found) = graph_follows.find(stored, target_did) - assert found.rkey == "3bbb" - assert graph_follows.find( - [stored_follow("3aaa", "did:plc:other")], - target_did, - ) - == None -} - -fn list_records_body(records: List(json.Json)) -> String { - json.object([#("records", json.preprocessed_array(records))]) - |> json.to_string -} - fn follow_record_json(rkey: String, subject: String) -> json.Json { json.object([ - #( - "uri", - json.string("at://did:plc:x/dev.mokkenstorm.crate.graph.follow/" <> rkey), - ), + #("uri", json.string(follow_uri(rkey))), #("cid", json.string("bafy" <> rkey)), #( "value", @@ -84,12 +66,14 @@ fn follow_record_json(rkey: String, subject: String) -> json.Json { ]) } +fn list_records_body(records: List(json.Json)) -> String { + json.object([#("records", json.preprocessed_array(records))]) + |> json.to_string +} + fn create_record_response(rkey: String) -> String { json.object([ - #( - "uri", - json.string("at://did:plc:x/dev.mokkenstorm.crate.graph.follow/" <> rkey), - ), + #("uri", json.string(follow_uri(rkey))), #("cid", json.string("bafy" <> rkey)), ]) |> json.to_string @@ -101,12 +85,6 @@ fn ok_response( Ok(response.Response(200, [], bit_array.from_string(body))) } -fn forbidden_response( - body: String, -) -> Result(response.Response(BitArray), xrpc.TransportError) { - Ok(response.Response(403, [], bit_array.from_string(body))) -} - fn network_client(list_body: String, create_body: String) -> xrpc.Client { xrpc.Client(send: fn(req) { case req.path { @@ -123,115 +101,60 @@ fn missing_scope_client() -> xrpc.Client { case req.path { "/xrpc/com.atproto.repo.listRecords" -> ok_response(list_records_body([])) "/xrpc/com.atproto.repo.putRecord" -> - forbidden_response("{\"error\":\"InsufficientScope\"}") + Ok(response.Response( + 403, + [], + bit_array.from_string("{\"error\":\"InsufficientScope\"}"), + )) _ -> panic as { "unexpected path: " <> req.path } } }) } -fn test_context(client: xrpc.Client) -> #(Context, config.Config) { - let cfg = support.stub_config(client) - let ctx = - support.stub_context_with( - cfg, - support.unreachable_catalog_deps(), - fn(_req) { Error("unused") }, - [], - ) - #(ctx, cfg) -} - -fn authed_post( - path: String, - body: json.Json, - cfg: config.Config, -) -> wisp.Request { - let assert Ok(id) = session_store.create(cfg.sessions, a_session()) - simulate.request(http.Post, path) - |> simulate.json_body(body) - |> simulate.cookie(session_cookie, id, wisp.Signed) -} - -pub fn follow_creates_a_record_and_returns_its_uri_test() { - let #(ctx, cfg) = - test_context(network_client( - list_records_body([]), - create_record_response("3ccc"), - )) - let req = - authed_post( - support.xrpc("graph.followUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - let resp = graph.follow(req, ctx) - assert resp.status == 200 - let body = simulate.read_body(resp) - assert support.field_string(body, ["uri"]) - == Ok("at://did:plc:x/dev.mokkenstorm.crate.graph.follow/3ccc") -} - -pub fn follow_requires_authentication_test() { - let #(ctx, _cfg) = test_context(network_client(list_records_body([]), "")) - let req = - simulate.request(http.Post, support.xrpc("graph.followUser")) - |> simulate.json_body(json.object([#("subject", json.string(target_did))])) - assert graph.follow(req, ctx).status == 401 -} - -pub fn follow_scope_failure_is_exposed_as_a_reauthorization_signal_test() { - let #(ctx, cfg) = test_context(missing_scope_client()) - let req = - authed_post( - support.xrpc("graph.followUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - let resp = graph.follow(req, ctx) - assert resp.status == 403 - let body = simulate.read_body(resp) - assert support.field_string(body, ["error"]) == Ok("Forbidden") - assert support.field_string(body, ["message"]) - == Ok( - "your session needs permission to follow collectors; sign in again to approve it", - ) -} - -pub fn unfollow_deletes_the_matching_follow_and_returns_ok_test() { - let #(ctx, cfg) = - test_context(network_client( - list_records_body([follow_record_json("3ddd", target_did)]), - "", - )) - let req = - authed_post( - support.xrpc("graph.unfollowUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - let resp = graph.unfollow(req, ctx) - assert resp.status == 200 +// Panics on listRecords, so any lookup that reaches it failed to use the index. +fn no_list_client() -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.deleteRecord" -> ok_response("{}") + "/xrpc/com.atproto.repo.putRecord" -> + ok_response(create_record_response("3ccc")) + _ -> panic as { "unexpected PDS call: " <> req.path } + } + }) } -pub fn unfollow_returns_404_when_no_follow_exists_test() { - let #(ctx, cfg) = test_context(network_client(list_records_body([]), "")) - let req = - authed_post( - support.xrpc("graph.unfollowUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - let resp = graph.unfollow(req, ctx) - assert resp.status == 404 +fn no_create_client(list_body: String) -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.listRecords" -> ok_response(list_body) + "/xrpc/com.atproto.repo.deleteRecord" -> ok_response("{}") + "/xrpc/com.atproto.repo.createRecord" -> + panic as "idempotent follow attempted a create" + _ -> panic as { "unexpected PDS call: " <> req.path } + } + }) } -const viewer_did = "did:plc:x" - -fn follow_uri(rkey: String) -> String { - "at://" <> viewer_did <> "/dev.mokkenstorm.crate.graph.follow/" <> rkey +// Fails deleting the second (later-sorted) rkey, succeeds on the first. +fn partial_delete_failure_client() -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.deleteRecord" -> + case bit_array.to_string(req.body) { + Ok(body) -> + case string.contains(body, "3bbb") { + True -> + Error(core_xrpc.ConnectionFailed("PDS unreachable for 3bbb")) + False -> ok_response("{}") + } + Error(_) -> panic as "expected a decodable delete body" + } + _ -> panic as { "unexpected PDS call: " <> req.path } + } + }) } -fn test_context_with( +fn test_context( client: xrpc.Client, follows: follow_index.Store, ) -> #(Context, config.Config) { @@ -249,176 +172,183 @@ fn test_context_with( #(ctx, cfg) } -/// Fails any listRecords: the assertion that a lookup came from the index. -fn no_list_client() -> xrpc.Client { - xrpc.Client(send: fn(req) { - case req.path { - "/xrpc/com.atproto.repo.deleteRecord" -> ok_response("{}") - "/xrpc/com.atproto.repo.putRecord" -> - ok_response(create_record_response("3ccc")) - _ -> panic as { "unexpected PDS call: " <> req.path } - } - }) +fn authed_post(path: String, cfg: config.Config) -> wisp.Request { + let assert Ok(id) = session_store.create(cfg.sessions, a_session()) + simulate.request(http.Post, support.xrpc(path)) + |> simulate.json_body(json.object([#("subject", json.string(target_did))])) + |> simulate.cookie(session_cookie, id, wisp.Signed) } -fn no_create_client(list_body: String) -> xrpc.Client { - xrpc.Client(send: fn(req) { - case req.path { - "/xrpc/com.atproto.repo.listRecords" -> ok_response(list_body) - "/xrpc/com.atproto.repo.deleteRecord" -> ok_response("{}") - "/xrpc/com.atproto.repo.createRecord" -> - panic as "idempotent follow attempted a create" - _ -> panic as { "unexpected PDS call: " <> req.path } - } +fn call( + handler: fn(wisp.Request, Context) -> wisp.Response, + path: String, + client: xrpc.Client, + follows: follow_index.Store, +) -> wisp.Response { + let #(ctx, cfg) = test_context(client, follows) + handler(authed_post(path, cfg), ctx) +} + +fn seeded_index(edges: List(#(String, String))) -> follow_index.Store { + let follows = support.fresh_follow_index() + list.each(edges, fn(edge) { + let #(rkey, created_at) = edge + follows.edges.upsert(follow_index.Follow( + follow_uri: follow_uri(rkey), + did: viewer_did, + subject: target_did, + created_at:, + )) }) + follows.seen.mark_seen(viewer_did) + follows +} + +pub fn find_locates_the_stored_follow_or_returns_none_test() { + let stored = [ + stored_follow("3aaa", "did:plc:other"), + stored_follow("3bbb", target_did), + ] + let assert Some(found) = graph_follows.find(stored, target_did) + assert found.rkey == "3bbb" + assert graph_follows.find( + [stored_follow("3aaa", "did:plc:other")], + target_did, + ) + == None } -pub fn follow_is_idempotent_and_chooses_a_stable_legacy_record_test() { - let #(ctx, cfg) = +pub fn follow_requires_authentication_test() { + let #(ctx, _cfg) = test_context( + network_client(list_records_body([]), ""), + support.fresh_follow_index(), + ) + let req = + simulate.request(http.Post, support.xrpc("graph.followUser")) + |> simulate.json_body(json.object([#("subject", json.string(target_did))])) + assert graph.follow(req, ctx).status == 401 +} + +// A fresh follow writes a record; an existing one reuses the lowest-sorting +// legacy rkey instead of creating a second. +pub fn follow_returns_a_stable_record_uri_test() { + [ + #( + network_client(list_records_body([]), create_record_response("3ccc")), + "3ccc", + ), + #( no_create_client( list_records_body([ follow_record_json("3bbb", target_did), follow_record_json("3aaa", target_did), ]), ), + "3aaa", + ), + ] + |> list.each(fn(row) { + let #(client, rkey) = row + let resp = + call( + graph.follow, + "graph.followUser", + client, + support.fresh_follow_index(), + ) + assert resp.status == 200 + assert support.field_string(simulate.read_body(resp), ["uri"]) + == Ok(follow_uri(rkey)) + }) +} + +pub fn follow_scope_failure_is_exposed_as_a_reauthorization_signal_test() { + let resp = + call( + graph.follow, + "graph.followUser", + missing_scope_client(), + support.fresh_follow_index(), ) - let req = - authed_post( - support.xrpc("graph.followUser"), - json.object([#("subject", json.string(target_did))]), - cfg, + assert resp.status == 403 + let body = simulate.read_body(resp) + assert support.field_string(body, ["error"]) == Ok("Forbidden") + assert support.field_string(body, ["message"]) + == Ok( + "your session needs permission to follow collectors; sign in again to approve it", ) - let body = simulate.read_body(graph.follow(req, ctx)) - assert support.field_string(body, ["uri"]) - == Ok("at://did:plc:x/dev.mokkenstorm.crate.graph.follow/3aaa") } pub fn follow_mirrors_the_written_record_into_the_index_test() { - let assert Ok(follows) = follow_index.start() - follows.seen.mark_seen(viewer_did) - let #(ctx, cfg) = test_context_with(no_list_client(), follows) - let req = - authed_post( - support.xrpc("graph.followUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - assert graph.follow(req, ctx).status == 200 + let follows = seeded_index([]) + assert call(graph.follow, "graph.followUser", no_list_client(), follows).status + == 200 assert follows.edges.following(viewer_did) == [target_did] let assert Some(edge) = follows.edges.find(viewer_did, target_did) assert edge.follow_uri == follow_uri("3ccc") } -pub fn unfollow_finds_the_rkey_in_the_index_without_a_pds_list_test() { - let assert Ok(follows) = follow_index.start() - follows.edges.upsert(follow_index.Follow( - follow_uri: follow_uri("3ddd"), - did: viewer_did, - subject: target_did, - created_at: "2026-01-01T00:00:00Z", - )) - follows.seen.mark_seen(viewer_did) - let #(ctx, cfg) = test_context_with(no_list_client(), follows) - let req = - authed_post( - support.xrpc("graph.unfollowUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - assert graph.unfollow(req, ctx).status == 200 - assert follows.edges.following(viewer_did) == [] -} - -pub fn unfollow_removes_all_legacy_duplicate_index_edges_test() { - let assert Ok(follows) = follow_index.start() - follows.edges.upsert(follow_index.Follow( - follow_uri: follow_uri("3aaa"), - did: viewer_did, - subject: target_did, - created_at: "2026-01-01T00:00:00Z", - )) - follows.edges.upsert(follow_index.Follow( - follow_uri: follow_uri("3bbb"), - did: viewer_did, - subject: target_did, - created_at: "2026-01-02T00:00:00Z", - )) - follows.seen.mark_seen(viewer_did) - let #(ctx, cfg) = test_context_with(no_list_client(), follows) - let req = - authed_post( - support.xrpc("graph.unfollowUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - assert graph.unfollow(req, ctx).status == 200 - assert follows.edges.following(viewer_did) == [] -} - -pub fn unfollow_404s_from_the_index_when_the_edge_is_absent_test() { - let assert Ok(follows) = follow_index.start() - follows.seen.mark_seen(viewer_did) - let #(ctx, cfg) = test_context_with(no_list_client(), follows) - let req = - authed_post( - support.xrpc("graph.unfollowUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - assert graph.unfollow(req, ctx).status == 404 +// Unfollow resolves the rkey from the index rather than listing the PDS, and +// takes every duplicate legacy edge for the subject with it. +pub fn unfollow_clears_index_edges_without_a_pds_list_test() { + [ + #([#("3ddd", "2026-01-01T00:00:00Z")], 200), + #( + [#("3aaa", "2026-01-01T00:00:00Z"), #("3bbb", "2026-01-02T00:00:00Z")], + 200, + ), + #([], 404), + ] + |> list.each(fn(row) { + let #(edges, status) = row + let follows = seeded_index(edges) + let resp = + call(graph.unfollow, "graph.unfollowUser", no_list_client(), follows) + assert resp.status == status + assert follows.edges.following(viewer_did) == [] + }) } -/// Fails deleting the second (later-sorted) rkey, succeeds on the first. -fn partial_delete_failure_client() -> xrpc.Client { - xrpc.Client(send: fn(req) { - case req.path { - "/xrpc/com.atproto.repo.deleteRecord" -> - case bit_array.to_string(req.body) { - Ok(body) -> - case string.contains(body, "3bbb") { - True -> - Error(core_xrpc.ConnectionFailed("PDS unreachable for 3bbb")) - False -> ok_response("{}") - } - Error(_) -> panic as "expected a decodable delete body" - } - _ -> panic as { "unexpected PDS call: " <> req.path } - } +pub fn unfollow_without_the_index_falls_back_to_the_pds_test() { + [ + #(list_records_body([follow_record_json("3ddd", target_did)]), 200), + #(list_records_body([]), 404), + ] + |> list.each(fn(row) { + let #(list_body, status) = row + let resp = + call( + graph.unfollow, + "graph.unfollowUser", + network_client(list_body, ""), + support.fresh_follow_index(), + ) + assert resp.status == status }) } pub fn unfollow_index_keeps_only_edges_the_pds_delete_failed_on_test() { - let assert Ok(follows) = follow_index.start() - follows.edges.upsert(follow_index.Follow( - follow_uri: follow_uri("3aaa"), - did: viewer_did, - subject: target_did, - created_at: "2026-01-01T00:00:00Z", - )) - follows.edges.upsert(follow_index.Follow( - follow_uri: follow_uri("3bbb"), - did: viewer_did, - subject: target_did, - created_at: "2026-01-02T00:00:00Z", - )) - follows.seen.mark_seen(viewer_did) - let #(ctx, cfg) = test_context_with(partial_delete_failure_client(), follows) - let req = - authed_post( - support.xrpc("graph.unfollowUser"), - json.object([#("subject", json.string(target_did))]), - cfg, + let follows = + seeded_index([ + #("3aaa", "2026-01-01T00:00:00Z"), + #("3bbb", "2026-01-02T00:00:00Z"), + ]) + let resp = + call( + graph.unfollow, + "graph.unfollowUser", + partial_delete_failure_client(), + follows, ) - assert graph.unfollow(req, ctx).status == 502 + assert resp.status == 502 let assert Some(edge) = follows.edges.find(viewer_did, target_did) assert edge.follow_uri == follow_uri("3bbb") } pub fn deterministic_rkey_is_stable_and_atproto_safe_test() { let first = graph_follows.rkey_for_subject(target_did) - let second = graph_follows.rkey_for_subject(target_did) - assert first == second + assert first == graph_follows.rkey_for_subject(target_did) assert string.starts_with(first, "f_") assert string.contains(first, ":") == False } @@ -430,7 +360,7 @@ pub fn concurrent_follows_converge_on_the_same_put_rkey_test() { case req.path { "/xrpc/com.atproto.repo.listRecords" -> ok_response(list_records_body([])) - "/xrpc/com.atproto.repo.putRecord" -> { + "/xrpc/com.atproto.repo.putRecord" -> case bit_array.to_string(req.body) { Ok(body) -> case string.contains(body, expected_rkey) { @@ -443,29 +373,19 @@ pub fn concurrent_follows_converge_on_the_same_put_rkey_test() { _ -> Error(core_xrpc.ConnectionFailed("non-deterministic follow rkey")) } - } _ -> panic as { "unexpected PDS call: " <> req.path } } }) - let #(ctx, cfg) = test_context(client) - let first = - authed_post( - support.xrpc("graph.followUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) - let second = - authed_post( - support.xrpc("graph.followUser"), - json.object([#("subject", json.string(target_did))]), - cfg, - ) + let #(ctx, cfg) = test_context(client, support.fresh_follow_index()) let replies = process.new_subject() - process.spawn_unlinked(fn() { - process.send(replies, graph.follow(first, ctx).status) - }) - process.spawn_unlinked(fn() { - process.send(replies, graph.follow(second, ctx).status) + let requests = [ + authed_post("graph.followUser", cfg), + authed_post("graph.followUser", cfg), + ] + list.each(requests, fn(req) { + process.spawn_unlinked(fn() { + process.send(replies, graph.follow(req, ctx).status) + }) }) let assert Ok(a) = process.receive(replies, 1000) let assert Ok(b) = process.receive(replies, 1000) -- 2.51.2 From 1e1b908b903902b1a2b7f0798a3bc0c5960344c8 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 16:58:44 +0200 Subject: [PATCH 09/16] test: compact the web feed tests into per-behavior tables --- web/test/feed_test.gleam | 395 ++++++++++++++++++--------------------- 1 file changed, 179 insertions(+), 216 deletions(-) diff --git a/web/test/feed_test.gleam b/web/test/feed_test.gleam index e46cc5b..832d4fe 100644 --- a/web/test/feed_test.gleam +++ b/web/test/feed_test.gleam @@ -77,6 +77,15 @@ fn fe(handle: String, updated_at: String) -> EntryDetail { ) } +fn cached( + did: String, + entry_id: String, + handle: String, + updated_at: String, +) -> #(#(String, String), EntryDetail) { + #(#(did, entry_id), fe(handle, updated_at)) +} + fn with_feed( items: List(FeedItem), fallback: Bool, @@ -105,29 +114,37 @@ fn occurrences(haystack: String, needle: String) -> Int { list.length(string.split(haystack, needle)) - 1 } -// --- states -------------------------------------------------------------- - -pub fn feed_view_renders_the_designed_empty_state_test() { - let html = render(with_feed([], False, [])) - assert string.contains(html, "class=\"empty-state\"") - assert string.contains(html, "THE FEED IS QUIET") - assert string.contains( - html, - "follow collectors from their public crates to see what they add, want, and fix", - ) - assert string.contains(html, "BROWSE THE NETWORK") - assert string.contains(html, "href=\"/browse\"") +fn assert_copy( + html: String, + present: List(String), + absent: List(String), +) -> Nil { + list.each(present, fn(needle) { + assert string.contains(html, needle) + }) + list.each(absent, fn(needle) { + assert !string.contains(html, needle) + }) } -pub fn feed_loading_state_shows_a_fetching_placeholder_test() { - let html = render(Model(..logged_in(), feed: FeedLoading)) - assert string.contains(html, "FETCHING") -} +// --- states -------------------------------------------------------------- -pub fn feed_failed_state_shows_the_couldnt_load_copy_test() { - let html = render(Model(..logged_in(), feed: FeedFailed)) - assert string.contains(html, "error-state__sticker") - assert string.contains(html, "load the feed right now") +pub fn each_feed_state_renders_its_own_copy_test() { + [ + #(with_feed([], False, []), [ + "class=\"empty-state\"", "THE FEED IS QUIET", + "follow collectors from their public crates to see what they add, want, and fix", + "BROWSE THE NETWORK", "href=\"/browse\"", + ]), + #(Model(..logged_in(), feed: FeedLoading), ["FETCHING"]), + #(Model(..logged_in(), feed: FeedFailed), [ + "error-state__sticker", "load the feed right now", + ]), + ] + |> list.each(fn(row) { + let #(model, present) = row + assert_copy(render(model), present, []) + }) } pub fn got_feed_skeleton_error_marks_failed_and_notices_test() { @@ -144,119 +161,92 @@ pub fn got_feed_skeleton_error_marks_failed_and_notices_test() { assert string.contains(render(model), "load the feed right now") } -// --- reason rows: hydrated ---------------------------------------------- - -pub fn single_reason_hydrated_renders_header_and_cover_row_test() { - let html = - render( - with_feed([single("did:a", "e0", "added")], False, [ - #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), - ]), - ) - assert string.contains(html, "@alice.test added") - assert string.contains(html, "Spiderland") - assert string.contains(html, "class=\"row\"") - assert string.contains(html, "href=\"/u/alice.test/record/e0\"") -} - -pub fn actor_batch_hydrated_renders_count_and_strip_test() { - let refs = [ref("did:a", "e0"), ref("did:a", "e1")] - let html = - render( - with_feed([batch(refs, "added")], False, [ - #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), - #(#("did:a", "e1"), fe("alice.test", "2026-07-18T11:00:00Z")), - ]), - ) - assert string.contains(html, "@alice.test added 2 records") - assert string.contains(html, "class=\"feed-strip\"") -} - -pub fn actor_batch_partial_hydration_does_not_credit_later_actor_with_full_batch_test() { - let refs = [ref("did:a", "e0"), ref("did:b", "e1")] - let html = - render( - with_feed([batch(refs, "added")], False, [ - #(#("did:b", "e1"), fe("bob.test", "2026-07-18T11:00:00Z")), - ]), - ) - assert string.contains( - html, - "@bob.test added 1 record, plus 1 unresolved record", - ) - assert !string.contains(html, "@bob.test added 2 records") -} - -pub fn subject_converge_hydrated_links_to_the_pressing_test() { - let refs = [ref("did:a", "e0"), ref("did:b", "e9")] - let subject = "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1" - let html = - render( - with_feed([converge(refs, subject)], False, [ - #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), - #(#("did:b", "e9"), fe("bob.test", "2026-07-18T11:00:00Z")), - ]), - ) - assert string.contains( - html, - "@alice.test and @bob.test picked up the same pressing", - ) - assert string.contains(html, "class=\"feed-avatar-cluster\"") - assert string.contains(html, "aria-label=\"@alice.test\"") - assert string.contains(html, "aria-label=\"@bob.test\"") - assert string.contains(html, "href=\"/pressing/did:x/rk1\"") -} +// --- reason rows --------------------------------------------------------- -pub fn subject_converge_partial_hydration_names_known_and_unresolved_test() { - let refs = [ref("did:a", "e0"), ref("did:b", "e9"), ref("did:c", "e8")] +// Each reason's copy when fully hydrated, and its "plus N unresolved" form +// when the cache is missing refs: a partially hydrated row must never credit +// the one actor it does know with the whole batch. +pub fn reason_rows_render_hydrated_and_partial_copy_test() { + let alice_e0 = cached("did:a", "e0", "alice.test", "2026-07-18T10:00:00Z") + let alice_e1 = cached("did:a", "e1", "alice.test", "2026-07-18T11:00:00Z") + let bob_e1 = cached("did:b", "e1", "bob.test", "2026-07-18T11:00:00Z") + let bob_e9 = cached("did:b", "e9", "bob.test", "2026-07-18T11:00:00Z") let subject = "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1" - let html = - render( - with_feed([converge(refs, subject)], False, [ - #(#("did:b", "e9"), fe("bob.test", "2026-07-18T11:00:00Z")), - ]), - ) - let label = + let converge_partial = "@bob.test, plus 2 unresolved collectors picked up the same pressing" - assert string.contains(html, label) - assert string.contains(html, "aria-label=\"" <> label <> "\"") - assert string.contains(html, "class=\"feed-avatar-cluster\"") - assert string.contains(html, "href=\"/pressing/did:x/rk1\"") -} - -pub fn import_reason_via_line_reflects_the_source_test() { - let refs = [ref("did:a", "e0")] - let hydrated = [#(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z"))] - - let with_source = - render(with_feed([import_item(refs, Some("discogs"))], False, hydrated)) - assert string.contains(with_source, "@alice.test imported 1 record") - assert string.contains(with_source, "via Discogs") - assert string.contains(with_source, "class=\"feed-strip\"") - - let without_source = - render(with_feed([import_item(refs, None)], False, hydrated)) - assert string.contains(without_source, "@alice.test imported 1 record") - assert !string.contains(without_source, " via ") -} - -pub fn import_partial_hydration_uses_unresolved_copy_test() { - let refs = [ref("did:a", "e0"), ref("did:b", "e1")] - let html = - render( - with_feed([import_item(refs, Some("discogs"))], False, [ - #(#("did:b", "e1"), fe("bob.test", "2026-07-18T11:00:00Z")), - ]), - ) - assert string.contains( - html, - "@bob.test imported 1 record, plus 1 unresolved record via Discogs", - ) - assert !string.contains(html, "@bob.test imported 2 records via Discogs") + [ + #( + single("did:a", "e0", "added"), + [alice_e0], + [ + "@alice.test added", "Spiderland", "class=\"row\"", + "href=\"/u/alice.test/record/e0\"", + ], + [], + ), + #( + batch([ref("did:a", "e0"), ref("did:a", "e1")], "added"), + [alice_e0, alice_e1], + ["@alice.test added 2 records", "class=\"feed-strip\""], + [], + ), + #( + batch([ref("did:a", "e0"), ref("did:b", "e1")], "added"), + [bob_e1], + ["@bob.test added 1 record, plus 1 unresolved record"], + ["@bob.test added 2 records"], + ), + #( + converge([ref("did:a", "e0"), ref("did:b", "e9")], subject), + [alice_e0, bob_e9], + [ + "@alice.test and @bob.test picked up the same pressing", + "class=\"feed-avatar-cluster\"", "aria-label=\"@alice.test\"", + "aria-label=\"@bob.test\"", "href=\"/pressing/did:x/rk1\"", + ], + [], + ), + #( + converge( + [ref("did:a", "e0"), ref("did:b", "e9"), ref("did:c", "e8")], + subject, + ), + [bob_e9], + [ + converge_partial, + "aria-label=\"" <> converge_partial <> "\"", + "class=\"feed-avatar-cluster\"", + "href=\"/pressing/did:x/rk1\"", + ], + [], + ), + #( + import_item([ref("did:a", "e0")], Some("discogs")), + [alice_e0], + ["@alice.test imported 1 record", "via Discogs", "class=\"feed-strip\""], + [], + ), + #( + import_item([ref("did:a", "e0")], None), + [alice_e0], + [ + "@alice.test imported 1 record", + ], + [" via "], + ), + #( + import_item([ref("did:a", "e0"), ref("did:b", "e1")], Some("discogs")), + [bob_e1], + ["@bob.test imported 1 record, plus 1 unresolved record via Discogs"], + ["@bob.test imported 2 records via Discogs"], + ), + ] + |> list.each(fn(row) { + let #(item, cache, present, absent) = row + assert_copy(render(with_feed([item], False, cache)), present, absent) + }) } -// --- reason rows: unhydrated (skeleton, no crash) ----------------------- - pub fn every_reason_renders_a_skeleton_when_unhydrated_test() { let refs = [ref("did:a", "e0"), ref("did:b", "e1")] [ @@ -266,15 +256,15 @@ pub fn every_reason_renders_a_skeleton_when_unhydrated_test() { import_item(refs, Some("discogs")), ] |> list.each(fn(item) { - let html = render(with_feed([item], False, [])) - assert string.contains(html, "feed-row--skeleton") + assert string.contains( + render(with_feed([item], False, [])), + "feed-row--skeleton", + ) }) } -// --- fallback label ------------------------------------------------------ - pub fn fallback_label_shows_only_when_true_test() { - let hydrated = [#(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z"))] + let hydrated = [cached("did:a", "e0", "alice.test", "2026-07-18T10:00:00Z")] [True, False] |> list.each(fn(fallback) { let html = @@ -285,29 +275,21 @@ pub fn fallback_label_shows_only_when_true_test() { // --- day separators ------------------------------------------------------ -pub fn day_separator_count_reflects_distinct_and_same_hydrated_days_test() { +pub fn day_separators_count_distinct_hydrated_days_test() { let items = [single("did:a", "e0", "added"), single("did:a", "e1", "added")] - let e0 = #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")) - - let different_days = - render( - with_feed(items, False, [ - e0, - #(#("did:a", "e1"), fe("alice.test", "2026-07-17T10:00:00Z")), - ]), - ) - assert occurrences(different_days, "class=\"feed-day\"") == 2 - assert string.contains(different_days, "2026-07-18") - assert string.contains(different_days, "2026-07-17") - - let same_day = - render( - with_feed(items, False, [ - e0, - #(#("did:a", "e1"), fe("alice.test", "2026-07-18T09:00:00Z")), - ]), - ) - assert occurrences(same_day, "class=\"feed-day\"") == 1 + let e0 = cached("did:a", "e0", "alice.test", "2026-07-18T10:00:00Z") + [#("2026-07-17T10:00:00Z", 2), #("2026-07-18T09:00:00Z", 1)] + |> list.each(fn(row) { + let #(second_day, expected) = row + let html = + render( + with_feed(items, False, [ + e0, + cached("did:a", "e1", "alice.test", second_day), + ]), + ) + assert occurrences(html, "class=\"feed-day\"") == expected + }) } // --- update: routing + pagination --------------------------------------- @@ -318,24 +300,26 @@ pub fn on_route_change_feed_sets_loading_and_fires_a_load_test() { assert effect != empty_effect() } -pub fn got_feed_skeleton_stores_items_and_hydrates_test() { - let data = FeedSkeletonData([single("did:a", "e0", "added")], False, None) - let #(model, effect) = - update(feed_model(), GotFeedSkeleton(feed_request(None), Ok(data))) - let assert FeedLoaded(items, _, _) = model.feed - assert list.length(items) == 1 - // Uncached refs mean a real hydration effect fires. - assert effect != empty_effect() -} - -pub fn got_feed_skeleton_with_no_items_fires_no_hydration_test() { - let data = FeedSkeletonData([], False, None) - let #(_, effect) = - update(feed_model(), GotFeedSkeleton(feed_request(None), Ok(data))) - assert effect == empty_effect() +// Hydration only fires for uncached refs, so an empty skeleton fires nothing. +pub fn got_feed_skeleton_hydrates_only_when_it_has_items_test() { + [#([single("did:a", "e0", "added")], 1, True), #([], 0, False)] + |> list.each(fn(row) { + let #(items, count, hydrates) = row + let #(model, effect) = + update( + feed_model(), + GotFeedSkeleton( + feed_request(None), + Ok(FeedSkeletonData(items, False, None)), + ), + ) + let assert FeedLoaded(loaded, _, _) = model.feed + assert list.length(loaded) == count + assert { effect != empty_effect() } == hydrates + }) } -pub fn got_feed_entry_ok_caches_the_entry_test() { +pub fn got_feed_entry_caches_only_on_success_test() { let data = EntryDetailData( did: "did:a", @@ -344,18 +328,16 @@ pub fn got_feed_entry_ok_caches_the_entry_test() { events: [], release: None, ) - let #(model, _) = update(logged_in(), GotFeedEntry("did:a", "e0", Ok(data))) - assert dict.has_key(model.feed_entries, #("did:a", "e0")) -} - -pub fn got_feed_entry_error_leaves_the_cache_untouched_test() { - let #(model, _) = + let #(ok_model, _) = + update(logged_in(), GotFeedEntry("did:a", "e0", Ok(data))) + assert dict.has_key(ok_model.feed_entries, #("did:a", "e0")) + let #(err_model, _) = update( logged_in(), GotFeedEntry("did:a", "e0", Error(support.network_error())), ) - assert dict.size(model.feed_entries) == 0 - assert model.notice == None + assert dict.size(err_model.feed_entries) == 0 + assert err_model.notice == None } pub fn got_feed_more_appends_and_dedupes_by_first_ref_test() { @@ -402,26 +384,12 @@ pub fn feed_show_more_fetches_only_with_a_cursor_and_not_already_loading_test() let seeded = Model(..feed_model(), feed: feed_state, feed_loading_more: loading) let #(_, effect) = update(seeded, FeedShowMore) - // A fetch fires only when a cursor exists and none is already in flight. assert { effect != empty_effect() } == expect_fetch }) } -pub fn stale_feed_skeleton_does_not_replace_the_current_route_test() { - let current = - Model( - ..feed_model(), - feed_generation: 2, - feed: FeedLoaded([single("did:a", "e0", "current")], None, False), - ) - let stale = FeedSkeletonData([single("did:b", "e1", "stale")], False, None) - let #(updated, effect) = - update(current, GotFeedSkeleton(FeedRequest(Feed, 1, None), Ok(stale))) - assert updated == current - assert effect == empty_effect() -} - -pub fn stale_feed_more_does_not_append_or_clear_loading_state_test() { +// A response from a superseded generation must not touch state or fire work. +pub fn a_stale_feed_response_is_ignored_test() { let current = Model( ..feed_model(), @@ -434,13 +402,15 @@ pub fn stale_feed_more_does_not_append_or_clear_loading_state_test() { feed_loading_more: True, ) let stale = FeedSkeletonData([single("did:b", "e1", "stale")], False, None) - let #(updated, effect) = - update( - current, - GotFeedMore(FeedRequest(Feed, 1, Some("cursor1")), Ok(stale)), - ) - assert updated == current - assert effect == empty_effect() + [ + GotFeedSkeleton(FeedRequest(Feed, 1, None), Ok(stale)), + GotFeedMore(FeedRequest(Feed, 1, Some("cursor1")), Ok(stale)), + ] + |> list.each(fn(message) { + let #(updated, effect) = update(current, message) + assert updated == current + assert effect == empty_effect() + }) } pub fn route_transition_clears_feed_loading_state_test() { @@ -464,32 +434,25 @@ pub fn feed_item_day_takes_the_newest_hydrated_day_test() { let item = batch([ref("did:a", "e0"), ref("did:a", "e1")], "added") let cache = dict.from_list([ - #(#("did:a", "e0"), fe("alice.test", "2026-07-17T10:00:00Z")), - #(#("did:a", "e1"), fe("alice.test", "2026-07-18T10:00:00Z")), + cached("did:a", "e0", "alice.test", "2026-07-17T10:00:00Z"), + cached("did:a", "e1", "alice.test", "2026-07-18T10:00:00Z"), ]) assert model.feed_item_day(item, cache) == Some("2026-07-18") + assert model.feed_item_day(single("did:a", "e0", "added"), dict.new()) == None } -pub fn feed_item_day_is_none_when_unhydrated_test() { - let item = single("did:a", "e0", "added") - assert model.feed_item_day(item, dict.new()) == None -} - -pub fn feed_item_key_is_the_first_ref_pair_test() { +pub fn feed_item_key_is_the_first_ref_pair_or_none_test() { let item = batch([ref("did:a", "e0"), ref("did:b", "e1")], "added") assert model.feed_item_key(item) == Some(#("did:a", "e0")) -} - -pub fn feed_item_key_is_none_for_an_empty_item_test() { - let item = + let empty = FeedItem( entries: [], reason: FeedItemReasonReasonSingle(ReasonSingle("added")), ) - assert model.feed_item_key(item) == None + assert model.feed_item_key(empty) == None } -// unused import guard: base() keeps the shared builder referenced here. +// Keeps `base()` referenced so the shared builder import stays used. pub fn base_model_has_a_feed_field_test() { assert base().feed == FeedLoading } -- 2.51.2 From 45f05e248f687edcafd2b87ff6b3b517efcda3b1 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 18:17:56 +0200 Subject: [PATCH 10/16] fix: bound social connection index reads --- server/src/crate_server/follow_index.gleam | 116 ++++++++++++- .../crate_server/follow_index_postgres.gleam | 159 ++++++++++++++++-- .../crate_server/handlers/connections.gleam | 108 ++++++------ server/src/crate_server/handlers/feed.gleam | 6 +- server/src/crate_server/handlers/graph.gleam | 6 +- server/src/crate_server/pagination.gleam | 2 +- server/src/crate_server/user_backfill.gleam | 9 +- server/test/connections_handler_test.gleam | 34 +++- server/test/follow_index_postgres_test.gleam | 19 ++- server/test/follow_index_test.gleam | 32 +++- server/test/graph_handler_test.gleam | 1 - web/src/crate_web/model.gleam | 4 - 12 files changed, 401 insertions(+), 95 deletions(-) diff --git a/server/src/crate_server/follow_index.gleam b/server/src/crate_server/follow_index.gleam index 11d3a6b..686383a 100644 --- a/server/src/crate_server/follow_index.gleam +++ b/server/src/crate_server/follow_index.gleam @@ -10,6 +10,7 @@ import gleam/dict.{type Dict} import gleam/erlang/process.{type Subject} import gleam/list import gleam/option.{type Option} +import gleam/order import gleam/otp/actor import gleam/result import gleam/set.{type Set} @@ -33,9 +34,13 @@ pub type EdgeOps { /// The dids `did` follows. Empty is ambiguous alone; pair with /// `SeenOps.has_seen`. following: fn(String) -> List(String), + following_page: fn(String, Option(String), Int) -> List(String), /// The dids that follow `did`. This reverse lookup is appview-derived and /// may be incomplete until every known actor has been backfilled. followers: fn(String) -> List(String), + followers_page: fn(String, Option(String), Int) -> List(String), + following_among: fn(String, List(String)) -> List(String), + followers_among: fn(String, List(String)) -> List(String), /// The unfollow path's lookup, served without a live `listRecords`. find: fn(String, String) -> Option(Follow), /// Every edge in a logical relationship. Legacy repos may contain @@ -44,7 +49,7 @@ pub type EdgeOps { /// Replace every indexed edge authored by `did` with a complete PDS read. /// The operation is atomic for the in-memory backend and transactional for /// the Postgres backend, so stale rows cannot survive a successful read. - replace_for_did: fn(String, List(Follow)) -> Nil, + replace_for_did: fn(String, List(Follow)) -> Result(Nil, String), /// Total indexed edges: a cheap completeness figure for `/debug/index`. count: fn() -> Int, ) @@ -72,10 +77,14 @@ type Msg { Delete(String) DeleteForDid(String) Following(String, Subject(List(String))) + FollowingPage(String, Option(String), Int, Subject(List(String))) Followers(String, Subject(List(String))) + FollowersPage(String, Option(String), Int, Subject(List(String))) + FollowingAmong(String, List(String), Subject(List(String))) + FollowersAmong(String, List(String), Subject(List(String))) Find(String, String, Subject(Option(Follow))) FindAll(String, String, Subject(List(Follow))) - ReplaceForDid(String, List(Follow)) + ReplaceForDid(String, List(Follow), Subject(Result(Nil, String))) Count(Subject(Int)) HasSeen(String, Subject(Bool)) MarkSeen(String) @@ -94,7 +103,19 @@ pub fn start() -> Result(Store, actor.StartError) { delete: fn(follow_uri) { process.send(subject, Delete(follow_uri)) }, delete_for_did: fn(did) { process.send(subject, DeleteForDid(did)) }, following: fn(did) { parallel.ask(subject, Following(did, _), or: []) }, + following_page: fn(did, after, limit) { + parallel.ask(subject, FollowingPage(did, after, limit, _), or: []) + }, followers: fn(did) { parallel.ask(subject, Followers(did, _), or: []) }, + followers_page: fn(did, after, limit) { + parallel.ask(subject, FollowersPage(did, after, limit, _), or: []) + }, + following_among: fn(did, candidates) { + parallel.ask(subject, FollowingAmong(did, candidates, _), or: []) + }, + followers_among: fn(did, candidates) { + parallel.ask(subject, FollowersAmong(did, candidates, _), or: []) + }, find: fn(did, subject_did) { parallel.ask(subject, Find(did, subject_did, _), or: option.None) }, @@ -102,7 +123,11 @@ pub fn start() -> Result(Store, actor.StartError) { parallel.ask(subject, FindAll(did, subject_did, _), or: []) }, replace_for_did: fn(did, follows) { - process.send(subject, ReplaceForDid(did, follows)) + parallel.ask( + subject, + ReplaceForDid(did, follows, _), + or: Error("follow index unavailable"), + ) }, count: fn() { parallel.ask(subject, Count, or: 0) }, ), @@ -141,6 +166,17 @@ fn handle(state: State, msg: Msg) -> actor.Next(State, Msg) { ) actor.continue(state) } + FollowingPage(did, after, limit, reply) -> { + process.send( + reply, + state.follows + |> matching_values(fn(follow) { follow.did == did }, fn(follow) { + follow.subject + }) + |> page_after(after, limit), + ) + actor.continue(state) + } Followers(did, reply) -> { process.send( reply, @@ -151,6 +187,45 @@ fn handle(state: State, msg: Msg) -> actor.Next(State, Msg) { ) actor.continue(state) } + FollowersPage(did, after, limit, reply) -> { + process.send( + reply, + state.follows + |> matching_values(fn(follow) { follow.subject == did }, fn(follow) { + follow.did + }) + |> page_after(after, limit), + ) + actor.continue(state) + } + FollowingAmong(did, candidates, reply) -> { + let candidate_set = set.from_list(candidates) + process.send( + reply, + state.follows + |> matching_values( + fn(follow) { + follow.did == did && set.contains(candidate_set, follow.subject) + }, + fn(follow) { follow.subject }, + ), + ) + actor.continue(state) + } + FollowersAmong(did, candidates, reply) -> { + let candidate_set = set.from_list(candidates) + process.send( + reply, + state.follows + |> matching_values( + fn(follow) { + follow.subject == did && set.contains(candidate_set, follow.did) + }, + fn(follow) { follow.did }, + ), + ) + actor.continue(state) + } Find(did, subject_did, reply) -> { process.send( reply, @@ -171,7 +246,7 @@ fn handle(state: State, msg: Msg) -> actor.Next(State, Msg) { ) actor.continue(state) } - ReplaceForDid(did, follows) -> { + ReplaceForDid(did, follows, reply) -> { let retained = dict.filter(state.follows, fn(_uri, follow) { follow.did != did }) let indexed = @@ -179,6 +254,7 @@ fn handle(state: State, msg: Msg) -> actor.Next(State, Msg) { |> list.fold(retained, fn(edges, follow) { dict.insert(edges, follow.follow_uri, follow) }) + process.send(reply, Ok(Nil)) actor.continue(State(..state, follows: indexed)) } Count(reply) -> { @@ -193,3 +269,35 @@ fn handle(state: State, msg: Msg) -> actor.Next(State, Msg) { actor.continue(State(..state, seen: set.insert(state.seen, did))) } } + +fn matching_values( + follows: Dict(String, Follow), + matches: fn(Follow) -> Bool, + value: fn(Follow) -> String, +) -> List(String) { + follows + |> dict.values + |> list.filter(matches) + |> list.map(value) + |> list.unique + |> list.sort(string.compare) +} + +fn page_after( + values: List(String), + after: Option(String), + limit: Int, +) -> List(String) { + let remaining = case after { + option.None -> values + option.Some(marker) -> + case list.contains(values, marker) { + True -> + list.drop_while(values, fn(value) { + string.compare(value, marker) != order.Gt + }) + False -> values + } + } + list.take(remaining, limit) +} diff --git a/server/src/crate_server/follow_index_postgres.gleam b/server/src/crate_server/follow_index_postgres.gleam index 8a34840..1758b3c 100644 --- a/server/src/crate_server/follow_index_postgres.gleam +++ b/server/src/crate_server/follow_index_postgres.gleam @@ -10,7 +10,7 @@ import crate_server/follow_index.{ } import crate_server/provenance import gleam/dynamic/decode -import gleam/list +import gleam/json import gleam/option.{type Option} import gleam/result import pog @@ -26,10 +26,22 @@ pub fn table_store(conn: pog.Connection) -> Result(Store, String) { delete: fn(follow_uri) { delete(conn, follow_uri) }, delete_for_did: fn(did) { delete_for_did(conn, did) }, following: fn(did) { following(conn, did) }, + following_page: fn(did, after, limit) { + following_page(conn, did, after, limit) + }, find: fn(did, subject) { find(conn, did, subject) }, find_all: fn(did, subject) { find_all(conn, did, subject) }, replace_for_did: fn(did, follows) { replace_for_did(conn, did, follows) }, followers: fn(did) { followers(conn, did) }, + followers_page: fn(did, after, limit) { + followers_page(conn, did, after, limit) + }, + following_among: fn(did, candidates) { + following_among(conn, did, candidates) + }, + followers_among: fn(did, candidates) { + followers_among(conn, did, candidates) + }, count: fn() { count(conn) }, ), seen: SeenOps(has_seen: fn(did) { has_seen(conn, did) }, mark_seen: fn(did) { @@ -59,6 +71,13 @@ fn migrate(conn: pog.Connection) -> Result(Nil, String) { |> pog.execute(conn) |> result.replace_error("follow_edges migration failed"), ) + use _ <- result.try( + pog.query( + "create index if not exists follow_edges_did_subject_idx on follow_edges (did, subject)", + ) + |> pog.execute(conn) + |> result.replace_error("follow_edges did subject index migration failed"), + ) // Every read is scoped to one follower. use _ <- result.try( pog.query( @@ -67,6 +86,13 @@ fn migrate(conn: pog.Connection) -> Result(Nil, String) { |> pog.execute(conn) |> result.replace_error("follow_edges did index migration failed"), ) + use _ <- result.try( + pog.query( + "create index if not exists follow_edges_subject_did_idx on follow_edges (subject, did)", + ) + |> pog.execute(conn) + |> result.replace_error("follow_edges subject did index migration failed"), + ) use _ <- result.try( pog.query( "create index if not exists follow_edges_subject_idx on follow_edges (subject)", @@ -136,6 +162,15 @@ fn following(conn: pog.Connection, did: String) -> List(String) { |> db.all(conn) } +fn following_page( + conn: pog.Connection, + did: String, + after: Option(String), + limit: Int, +) -> List(String) { + connection_page(conn, "did", "subject", did, after, limit) +} + fn find(conn: pog.Connection, did: String, subject: String) -> Option(Follow) { pog.query("select " <> columns <> " from follow_edges where did = $1 and subject = $2 @@ -164,35 +199,47 @@ fn replace_for_did( conn: pog.Connection, did: String, follows: List(Follow), -) -> Nil { - let result = +) -> Result(Nil, String) { + let encoded = + follows + |> json.array(fn(follow) { + json.object([ + #("follow_uri", json.string(follow.follow_uri)), + #("subject", json.string(follow.subject)), + #("created_at", json.string(follow.created_at)), + ]) + }) + |> json.to_string + let replaced = pog.transaction(conn, fn(transaction) { use _ <- result.try( pog.query("delete from follow_edges where did = $1") |> pog.parameter(pog.text(did)) |> pog.execute(transaction), ) - list.try_each(follows, fn(follow: Follow) { - pog.query( - "insert into follow_edges (follow_uri, did, subject, created_at) - values ($1, $2, $3, $4) + pog.query( + "insert into follow_edges (follow_uri, did, subject, created_at) + select follow_uri, $2, subject, created_at + from json_to_recordset($1::json) as rows( + follow_uri text, subject text, created_at text + ) on conflict (follow_uri) do update set did = excluded.did, subject = excluded.subject, created_at = excluded.created_at", - ) - |> pog.parameter(pog.text(follow.follow_uri)) - |> pog.parameter(pog.text(follow.did)) - |> pog.parameter(pog.text(follow.subject)) - |> pog.parameter(pog.text(follow.created_at)) - |> pog.execute(transaction) - }) + ) + |> pog.parameter(pog.text(encoded)) + |> pog.parameter(pog.text(did)) + |> pog.execute(transaction) |> result.map(fn(_) { Nil }) }) - case result { - Ok(_) -> Nil - Error(_) -> - wisp.log_warning("follow_edges reconciliation failed for " <> did) + |> result.replace_error("follow_edges reconciliation failed for " <> did) + case replaced { + Ok(Nil) -> Ok(Nil) + Error(message) -> { + wisp.log_warning(message) + Error(message) + } } } @@ -207,6 +254,82 @@ fn followers(conn: pog.Connection, subject: String) -> List(String) { |> db.all(conn) } +fn followers_page( + conn: pog.Connection, + subject: String, + after: Option(String), + limit: Int, +) -> List(String) { + connection_page(conn, "subject", "did", subject, after, limit) +} + +fn connection_page( + conn: pog.Connection, + scope_column: String, + value_column: String, + scope: String, + after: Option(String), + limit: Int, +) -> List(String) { + let row = { + use value <- decode.field("value", decode.string) + decode.success(value) + } + pog.query("with marker as ( + select $2::text as value + where $2::text is null or exists ( + select 1 from follow_edges + where " <> scope_column <> " = $1 and " <> value_column <> " = $2 + ) + ) + select distinct " <> value_column <> " as value + from follow_edges cross join marker + where " <> scope_column <> " = $1 + and (marker.value is null or " <> value_column <> " > marker.value) + order by value limit $3") + |> pog.parameter(pog.text(scope)) + |> pog.parameter(pog.nullable(pog.text, after)) + |> pog.parameter(pog.int(limit)) + |> pog.returning(row) + |> db.all(conn) +} + +fn following_among( + conn: pog.Connection, + did: String, + candidates: List(String), +) -> List(String) { + relationship_intersection(conn, "did", "subject", did, candidates) +} + +fn followers_among( + conn: pog.Connection, + subject: String, + candidates: List(String), +) -> List(String) { + relationship_intersection(conn, "subject", "did", subject, candidates) +} + +fn relationship_intersection( + conn: pog.Connection, + scope_column: String, + value_column: String, + scope: String, + candidates: List(String), +) -> List(String) { + let row = { + use value <- decode.field("value", decode.string) + decode.success(value) + } + pog.query("select distinct " <> value_column <> " as value from follow_edges + where " <> scope_column <> " = $1 and " <> value_column <> " = any($2) + order by value") + |> pog.parameter(pog.text(scope)) + |> pog.parameter(pog.array(pog.text, candidates)) + |> pog.returning(row) + |> db.all(conn) +} + fn count(conn: pog.Connection) -> Int { pog.query("select count(*)::int as count from follow_edges") |> db.count(conn) diff --git a/server/src/crate_server/handlers/connections.gleam b/server/src/crate_server/handlers/connections.gleam index 92ab363..15ecbb1 100644 --- a/server/src/crate_server/handlers/connections.gleam +++ b/server/src/crate_server/handlers/connections.gleam @@ -18,7 +18,7 @@ import gleam/dict.{type Dict} import gleam/int import gleam/json import gleam/list -import gleam/option.{None, Some} +import gleam/option.{type Option, None, Some} import gleam/result import gleam/set import gleam/string @@ -53,13 +53,25 @@ fn list_following( id: String, session: OauthSession, ) -> Response { - use client, session <- with_pds_client(ctx, id, session) - case graph_follows.load(client, session) { - Error(_) -> error_json(502, "could not load your follows from PDS") - Ok(stored) -> { - let following = list.map(stored, fn(row) { row.value.subject }) - index_follows(ctx, session.did, stored) - respond(ctx, session.did, Following, following, True, wisp.get_query(req)) + case ctx.follow_index.seen.has_seen(session.did) { + True -> respond_page(ctx, session.did, Following, True, wisp.get_query(req)) + False -> { + use client, session <- with_pds_client(ctx, id, session) + case graph_follows.load(client, session) { + Error(_) -> error_json(502, "could not load your follows from PDS") + Ok(stored) -> + case index_follows(ctx, session.did, stored) { + Error(_) -> error_json(503, "could not index your follows") + Ok(Nil) -> + respond_page( + ctx, + session.did, + Following, + True, + wisp.get_query(req), + ) + } + } } } } @@ -73,11 +85,7 @@ fn list_followers( session: OauthSession, ) -> Response { let known = ctx.follow_index.seen.has_seen(session.did) - let following = case known { - True -> ctx.follow_index.edges.following(session.did) - False -> [] - } - respond(ctx, session.did, Followers, following, known, wisp.get_query(req)) + respond_page(ctx, session.did, Followers, known, wisp.get_query(req)) } fn direction(query: List(#(String, String))) -> Result(Direction, Nil) { @@ -92,7 +100,7 @@ fn index_follows( ctx: Context, did: String, stored: List(StoredItem(GraphFollow)), -) -> Nil { +) -> Result(Nil, String) { let follows = stored |> list.map(fn(row) { @@ -103,39 +111,21 @@ fn index_follows( created_at: row.value.created_at, ) }) - ctx.follow_index.edges.replace_for_did(did, follows) + use _ <- result.map(ctx.follow_index.edges.replace_for_did(did, follows)) ctx.follow_index.seen.mark_seen(did) } -fn respond( +fn page_dids( ctx: Context, viewer_did: String, direction: Direction, - following: List(String), - following_complete: Bool, - query: List(#(String, String)), -) -> Response { - let followers = - ctx.follow_index.edges.followers(viewer_did) - |> list.unique - |> list.sort(string.compare) - let following_set = set.from_list(following) - let follower_set = set.from_list(followers) - let items = case direction { - Following -> - following - |> list.unique - |> list.sort(string.compare) - |> list.map(fn(did) { - connection(did, True, set.contains(follower_set, did)) - }) - Followers -> - followers - |> list.map(fn(did) { - connection(did, set.contains(following_set, did), True) - }) + after: Option(String), + limit: Int, +) -> List(String) { + case direction { + Following -> ctx.follow_index.edges.following_page(viewer_did, after, limit) + Followers -> ctx.follow_index.edges.followers_page(viewer_did, after, limit) } - respond_page(ctx, viewer_did, direction, items, following_complete, query) } fn connection( @@ -155,7 +145,6 @@ fn respond_page( ctx: Context, session_did: String, direction: Direction, - items: List(Connection), following_complete: Bool, query: List(#(String, String)), ) -> Response { @@ -164,14 +153,35 @@ fn respond_page( list.key_find(query, "limit") |> result.try(int.parse) |> option.from_result - let #(page, next_cursor) = - pagination.page( - items, - fn(item) { item.did }, - cursor_namespace(direction, session_did), - cursor, - Some(option.unwrap(limit, pagination.default_limit)), - ) + |> pagination.clamp_limit + let namespace = cursor_namespace(direction, session_did) + let after = cursor |> option.then(pagination.decode_cursor(namespace, _)) + let rows = page_dids(ctx, session_did, direction, after, limit + 1) + let page_dids = list.take(rows, limit) + let related = case direction, following_complete { + Following, _ -> + ctx.follow_index.edges.followers_among(session_did, page_dids) + Followers, True -> + ctx.follow_index.edges.following_among(session_did, page_dids) + Followers, False -> [] + } + let related_set = set.from_list(related) + let page = + page_dids + |> list.map(fn(did) { + case direction { + Following -> connection(did, True, set.contains(related_set, did)) + Followers -> connection(did, set.contains(related_set, did), True) + } + }) + let next_cursor = case list.length(rows) > limit { + False -> None + True -> + page_dids + |> list.last + |> result.map(fn(did) { pagination.encode_cursor(namespace, did) }) + |> option.from_result + } let identities = page |> list.map(fn(item) { item.did }) diff --git a/server/src/crate_server/handlers/feed.gleam b/server/src/crate_server/handlers/feed.gleam index c5bba37..9d0acb6 100644 --- a/server/src/crate_server/handlers/feed.gleam +++ b/server/src/crate_server/handlers/feed.gleam @@ -103,8 +103,10 @@ fn seed_follows( created_at: s.value.created_at, ) }) - ctx.follow_index.edges.replace_for_did(session.did, follows) - ctx.follow_index.seen.mark_seen(session.did) + case ctx.follow_index.edges.replace_for_did(session.did, follows) { + Ok(Nil) -> ctx.follow_index.seen.mark_seen(session.did) + Error(_) -> Nil + } list.map(stored, fn(s) { s.value.subject }) } } diff --git a/server/src/crate_server/handlers/graph.gleam b/server/src/crate_server/handlers/graph.gleam index 23bd83f..77e2780 100644 --- a/server/src/crate_server/handlers/graph.gleam +++ b/server/src/crate_server/handlers/graph.gleam @@ -176,8 +176,10 @@ fn index_stored_follows( created_at: row.value.created_at, ) }) - ctx.follow_index.edges.replace_for_did(session.did, follows) - ctx.follow_index.seen.mark_seen(session.did) + case ctx.follow_index.edges.replace_for_did(session.did, follows) { + Ok(Nil) -> ctx.follow_index.seen.mark_seen(session.did) + Error(_) -> Nil + } } fn delete_and_confirm( diff --git a/server/src/crate_server/pagination.gleam b/server/src/crate_server/pagination.gleam index ae36f5f..70b3ce9 100644 --- a/server/src/crate_server/pagination.gleam +++ b/server/src/crate_server/pagination.gleam @@ -100,7 +100,7 @@ fn resume_after( } } -fn clamp_limit(limit: Option(Int)) -> Int { +pub fn clamp_limit(limit: Option(Int)) -> Int { case limit { Some(n) if n >= 1 && n <= default_limit -> n _ -> default_limit diff --git a/server/src/crate_server/user_backfill.gleam b/server/src/crate_server/user_backfill.gleam index 4cb4827..64f9740 100644 --- a/server/src/crate_server/user_backfill.gleam +++ b/server/src/crate_server/user_backfill.gleam @@ -121,10 +121,11 @@ fn backfill_follows( )) }) case complete { - True -> { - index.edges.replace_for_did(user.did, rows) - index.seen.mark_seen(user.did) - } + True -> + case index.edges.replace_for_did(user.did, rows) { + Ok(Nil) -> index.seen.mark_seen(user.did) + Error(_) -> Nil + } False -> Nil } case complete { diff --git a/server/test/connections_handler_test.gleam b/server/test/connections_handler_test.gleam index f641bfc..72113f8 100644 --- a/server/test/connections_handler_test.gleam +++ b/server/test/connections_handler_test.gleam @@ -1,5 +1,3 @@ -//// Handler-level coverage for the read-only connections contract. - import atproto/xrpc import atproto_core/xrpc as core_xrpc import crate_server/context.{type Context, Context} @@ -175,9 +173,7 @@ pub fn an_unresolvable_identity_falls_back_to_the_did_test() { == Ok(["did:plc:unresolvable"]) } -// Following reads through to the PDS, so its failure is upstream failure -// rather than a silent fall back to whatever the index still remembers. -pub fn following_reports_a_pds_failure_and_keeps_the_index_test() { +pub fn known_following_is_served_from_the_index_without_the_pds_test() { let follows = support.fresh_follow_index() follows.edges.upsert(follower_edge("stale", "did:plc:me", "did:plc:stale")) follows.seen.mark_seen("did:plc:me") @@ -188,11 +184,13 @@ pub fn following_reports_a_pds_failure_and_keeps_the_index_test() { auth_session(), "graph.listConnections?direction=following", ) - assert resp.status == 502 + assert resp.status == 200 + assert support.field_nested(simulate.read_body(resp), ["items"], ["did"]) + == Ok(["did:plc:stale"]) assert follows.edges.following("did:plc:me") == ["did:plc:stale"] } -pub fn following_replaces_the_index_with_pds_data_test() { +pub fn unseen_following_reconciles_the_index_from_the_pds_test() { let follows = support.fresh_follow_index() follows.edges.upsert(follower_edge("stale", "did:plc:me", "did:plc:stale")) let resp = @@ -212,6 +210,28 @@ pub fn following_replaces_the_index_with_pds_data_test() { == ["did:plc:first", "did:plc:second"] } +pub fn unseen_following_does_not_mark_a_failed_reconciliation_seen_test() { + let base = support.fresh_follow_index() + let failing = + follow_index.Store( + ..base, + edges: follow_index.EdgeOps( + ..base.edges, + replace_for_did: fn(_did, _follows) { Error("write failed") }, + ), + ) + let resp = + respond( + failing, + list_records_client(["did:plc:first"]), + auth_session(), + "graph.listConnections?direction=following", + ) + + assert resp.status == 503 + assert base.seen.has_seen("did:plc:me") == False +} + pub fn a_connection_cursor_paginates_and_is_viewer_scoped_test() { let follows = support.fresh_follow_index() seed_followers(follows, "did:plc:me", ["did:plc:a", "did:plc:b"]) diff --git a/server/test/follow_index_postgres_test.gleam b/server/test/follow_index_postgres_test.gleam index 2d65897..8c35810 100644 --- a/server/test/follow_index_postgres_test.gleam +++ b/server/test/follow_index_postgres_test.gleam @@ -44,13 +44,28 @@ pub fn follow_round_trip_test() { == ["did:plc:subj-a", "did:plc:subj-b"] store.edges.upsert(follow("did:plc:other", did, "fr3")) assert store.edges.followers(did) == ["did:plc:other"] + assert store.edges.following_page(did, None, 1) == ["did:plc:subj-a"] + assert store.edges.following_page(did, Some("did:plc:subj-a"), 1) + == ["did:plc:subj-b"] + assert store.edges.followers_page(did, None, 1) == ["did:plc:other"] + assert store.edges.following_among(did, ["did:plc:subj-b"]) + == ["did:plc:subj-b"] + assert store.edges.followers_among(did, ["did:plc:other"]) + == ["did:plc:other"] + assert store.edges.following_among(did, []) == [] + assert store.edges.followers_among(did, []) == [] let assert Some(found) = store.edges.find(did, "did:plc:subj-a") assert found.created_at == "2024-01-01T00:00:00Z" assert store.edges.find(did, "did:plc:nobody") == None - store.edges.delete(follow(did, "did:plc:subj-a", "fr1").follow_uri) - assert store.edges.following(did) == ["did:plc:subj-b"] + assert store.edges.replace_for_did(did, [ + follow(did, "did:plc:replacement", "fr4"), + ]) + == Ok(Nil) + assert store.edges.following(did) == ["did:plc:replacement"] + assert store.edges.replace_for_did(did, []) == Ok(Nil) + assert store.edges.following(did) == [] store.edges.delete_for_did(did) assert store.edges.following(did) == [] } diff --git a/server/test/follow_index_test.gleam b/server/test/follow_index_test.gleam index 191f67f..bcacba6 100644 --- a/server/test/follow_index_test.gleam +++ b/server/test/follow_index_test.gleam @@ -73,11 +73,41 @@ pub fn replacing_a_dids_edges_reconciles_stale_rows_atomically_test() { store.edges.upsert(follow("did:a", "did:old", "f1")) store.edges.upsert(follow("did:a", "did:keep", "f2")) store.edges.upsert(follow("did:z", "did:old", "f3")) - store.edges.replace_for_did("did:a", [follow("did:a", "did:new", "f4")]) + assert store.edges.replace_for_did("did:a", [ + follow("did:a", "did:new", "f4"), + ]) + == Ok(Nil) assert store.edges.following("did:a") == ["did:new"] assert store.edges.following("did:z") == ["did:old"] } +pub fn connection_pages_are_distinct_bounded_and_resume_after_a_subject_test() { + let assert Ok(store) = follow_index.start() + store.edges.upsert(follow("did:a", "did:b", "f1")) + store.edges.upsert(follow("did:a", "did:b", "f2")) + store.edges.upsert(follow("did:a", "did:c", "f3")) + store.edges.upsert(follow("did:a", "did:d", "f4")) + store.edges.upsert(follow("did:z", "did:a", "f5")) + store.edges.upsert(follow("did:y", "did:a", "f6")) + + assert store.edges.following_page("did:a", None, 2) == ["did:b", "did:c"] + assert store.edges.following_page("did:a", Some("did:c"), 2) == ["did:d"] + assert store.edges.following_page("did:a", Some("did:missing"), 2) + == ["did:b", "did:c"] + assert store.edges.followers_page("did:a", None, 1) == ["did:y"] +} + +pub fn relationship_intersections_only_return_matching_candidates_test() { + let assert Ok(store) = follow_index.start() + store.edges.upsert(follow("did:a", "did:b", "f1")) + store.edges.upsert(follow("did:a", "did:c", "f2")) + store.edges.upsert(follow("did:b", "did:a", "f3")) + + assert store.edges.following_among("did:a", ["did:b", "did:z"]) == ["did:b"] + assert store.edges.followers_among("did:a", ["did:b", "did:z"]) == ["did:b"] + assert store.edges.following_among("did:a", []) == [] +} + pub fn delete_for_did_drops_that_dids_follows_and_seen_mark_test() { let assert Ok(store) = follow_index.start() store.edges.upsert(follow("did:a", "did:b", "f1")) diff --git a/server/test/graph_handler_test.gleam b/server/test/graph_handler_test.gleam index e76b7fd..f3de9e6 100644 --- a/server/test/graph_handler_test.gleam +++ b/server/test/graph_handler_test.gleam @@ -135,7 +135,6 @@ fn no_create_client(list_body: String) -> xrpc.Client { }) } -// Fails deleting the second (later-sorted) rkey, succeeds on the first. fn partial_delete_failure_client() -> xrpc.Client { xrpc.Client(send: fn(req) { case req.path { diff --git a/web/src/crate_web/model.gleam b/web/src/crate_web/model.gleam index ca5c899..0ee7848 100644 --- a/web/src/crate_web/model.gleam +++ b/web/src/crate_web/model.gleam @@ -822,14 +822,10 @@ pub type Model { pressing: PressingState, // The network feed's load state, for the /feed route. feed: FeedState, - // Monotonic identity for feed skeleton and pagination requests. feed_generation: Int, - // The signed-in viewer's following/followers list. connections: ConnectionsState, - // Monotonic identities for list requests and optimistic writes. connections_generation: Int, connection_write_generation: Int, - // The follow/unfollow write currently in flight, if any. connection_pending: Option(ConnectionWrite), connections_loading_more: Bool, // Hydrated feed entry refs, keyed #(actor_did, entry_id). -- 2.51.2 From 68b8cbbe29226d99dc4a9f199c7c9f8fc52af008 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Tue, 11 Aug 2026 09:48:14 +0200 Subject: [PATCH 11/16] fix: resume connection pages past a deleted cursor anchor --- server/src/crate_server/follow_index.gleam | 10 ++----- .../crate_server/follow_index_postgres.gleam | 13 ++------- server/test/follow_index_postgres_test.gleam | 29 +++++++++++++++++++ server/test/follow_index_test.gleam | 8 +++-- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/server/src/crate_server/follow_index.gleam b/server/src/crate_server/follow_index.gleam index 686383a..ea71727 100644 --- a/server/src/crate_server/follow_index.gleam +++ b/server/src/crate_server/follow_index.gleam @@ -291,13 +291,9 @@ fn page_after( let remaining = case after { option.None -> values option.Some(marker) -> - case list.contains(values, marker) { - True -> - list.drop_while(values, fn(value) { - string.compare(value, marker) != order.Gt - }) - False -> values - } + list.drop_while(values, fn(value) { + string.compare(value, marker) != order.Gt + }) } list.take(remaining, limit) } diff --git a/server/src/crate_server/follow_index_postgres.gleam b/server/src/crate_server/follow_index_postgres.gleam index 1758b3c..f742b70 100644 --- a/server/src/crate_server/follow_index_postgres.gleam +++ b/server/src/crate_server/follow_index_postgres.gleam @@ -275,17 +275,10 @@ fn connection_page( use value <- decode.field("value", decode.string) decode.success(value) } - pog.query("with marker as ( - select $2::text as value - where $2::text is null or exists ( - select 1 from follow_edges - where " <> scope_column <> " = $1 and " <> value_column <> " = $2 - ) - ) - select distinct " <> value_column <> " as value - from follow_edges cross join marker + pog.query("select distinct " <> value_column <> " as value + from follow_edges where " <> scope_column <> " = $1 - and (marker.value is null or " <> value_column <> " > marker.value) + and ($2::text is null or " <> value_column <> " > $2::text) order by value limit $3") |> pog.parameter(pog.text(scope)) |> pog.parameter(pog.nullable(pog.text, after)) diff --git a/server/test/follow_index_postgres_test.gleam b/server/test/follow_index_postgres_test.gleam index 8c35810..4eca9db 100644 --- a/server/test/follow_index_postgres_test.gleam +++ b/server/test/follow_index_postgres_test.gleam @@ -70,6 +70,35 @@ pub fn follow_round_trip_test() { assert store.edges.following(did) == [] } +pub fn connection_pages_resume_past_a_deleted_anchor_test() { + use store <- with_store() + let did = "did:plc:follow-anchor" + let follower = "did:plc:follow-anchor-er" + store.edges.delete_for_did(did) + store.edges.delete_for_did(follower) + store.edges.upsert(follow(did, "did:plc:anchor-a", "fa1")) + store.edges.upsert(follow(did, "did:plc:anchor-b", "fa2")) + store.edges.upsert(follow(did, "did:plc:anchor-c", "fa3")) + store.edges.upsert(follow(follower, did, "fa4")) + + assert store.edges.following_page(did, Some("did:plc:anchor-a"), 2) + == ["did:plc:anchor-b", "did:plc:anchor-c"] + + // The anchor is unfollowed between pages: the next page must resume past it + // rather than restart from the top or collapse to nothing. + store.edges.delete( + "at://" <> did <> "/dev.mokkenstorm.crate.graph.follow/fa2", + ) + assert store.edges.following_page(did, Some("did:plc:anchor-b"), 2) + == ["did:plc:anchor-c"] + assert store.edges.following_page(did, Some("did:plc:zzz"), 2) == [] + assert store.edges.followers_page(did, Some("did:plc:absent"), 2) + == [follower] + + store.edges.delete_for_did(did) + store.edges.delete_for_did(follower) +} + pub fn follow_seen_round_trip_test() { use store <- with_store() let did = "did:plc:follow-seen" diff --git a/server/test/follow_index_test.gleam b/server/test/follow_index_test.gleam index bcacba6..14f6c41 100644 --- a/server/test/follow_index_test.gleam +++ b/server/test/follow_index_test.gleam @@ -92,9 +92,13 @@ pub fn connection_pages_are_distinct_bounded_and_resume_after_a_subject_test() { assert store.edges.following_page("did:a", None, 2) == ["did:b", "did:c"] assert store.edges.following_page("did:a", Some("did:c"), 2) == ["did:d"] - assert store.edges.following_page("did:a", Some("did:missing"), 2) - == ["did:b", "did:c"] assert store.edges.followers_page("did:a", None, 1) == ["did:y"] + + // A cursor whose anchor was unfollowed between pages resumes at the next + // surviving subject rather than restarting, so page 2 never re-serves page 1. + store.edges.delete("at://did:a/dev.mokkenstorm.crate.graph.follow/f3") + assert store.edges.following_page("did:a", Some("did:c"), 2) == ["did:d"] + assert store.edges.following_page("did:a", Some("did:zzz"), 2) == [] } pub fn relationship_intersections_only_return_matching_candidates_test() { -- 2.51.2 From 16ac15e5079b39db43b829f0526e50ba3489e61f Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Tue, 11 Aug 2026 09:50:31 +0200 Subject: [PATCH 12/16] fix: resume pagination by position when a live cursor's anchor row is gone --- .../crate_server/handlers/connections.gleam | 6 +- server/src/crate_server/pagination.gleam | 104 ++++++++++++------ server/test/pagination_test.gleam | 66 +++++++++-- 3 files changed, 134 insertions(+), 42 deletions(-) diff --git a/server/src/crate_server/handlers/connections.gleam b/server/src/crate_server/handlers/connections.gleam index 15ecbb1..cc2af31 100644 --- a/server/src/crate_server/handlers/connections.gleam +++ b/server/src/crate_server/handlers/connections.gleam @@ -155,7 +155,11 @@ fn respond_page( |> option.from_result |> pagination.clamp_limit let namespace = cursor_namespace(direction, session_did) - let after = cursor |> option.then(pagination.decode_cursor(namespace, _)) + // The index resumes by comparison, so only the anchor did matters here. + let after = + cursor + |> option.then(pagination.decode_cursor(namespace, _)) + |> option.map(fn(point: pagination.ResumePoint) { point.last_id }) let rows = page_dids(ctx, session_did, direction, after, limit + 1) let page_dids = list.take(rows, limit) let related = case direction, following_complete { diff --git a/server/src/crate_server/pagination.gleam b/server/src/crate_server/pagination.gleam index 70b3ce9..6ba8ac3 100644 --- a/server/src/crate_server/pagination.gleam +++ b/server/src/crate_server/pagination.gleam @@ -1,34 +1,47 @@ //// Cursor pagination over an already-sorted, in-memory list. //// -//// Cursor convention: an opaque token, base64url(namespace <> ":" <> last -//// id) - scoped to a namespace (e.g. a shelf view) so switching filters can -//// never resume into the wrong slice, and never surfaced as an error: a -//// token that fails to decode, is scoped to another namespace, or names an -//// id no longer present in the list, all just restart the page from the -//// top. base64url (not standard base64) so the token is safe to drop -//// straight into a query string with no percent-encoding. +//// A cursor is base64url(namespace <> ":" <> last_id [<> "|" <> tail_length]). +//// The namespace stops a filter switch resuming into the wrong slice, and no +//// cursor is ever an error: unparseable, foreign or stale ones restart from +//// the top. `tail_length` (items following the anchor when it was minted) +//// resumes by position once the anchor row itself is gone, which holds only +//// while nothing is inserted after the anchor. import gleam/bit_array +import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string -/// The page size used when a cursor is given without an explicit `limit`, -/// and the ceiling any supplied `limit` is clamped to. +/// Default page size, and the ceiling on any supplied `limit`. pub const default_limit = 100 -/// Encode the opaque "resume after `last_id`" cursor for `namespace`. +/// The id to resume after, plus the tail length recorded at mint time. +pub type ResumePoint { + ResumePoint(last_id: String, tail_length: Option(Int)) +} + +/// Encode a cursor with no tail length: resumes by id, restarts once it is gone. pub fn encode_cursor(namespace: String, last_id: String) -> String { - bit_array.base64_url_encode( - bit_array.from_string(namespace <> ":" <> last_id), - False, - ) + encode_bytes(namespace <> ":" <> last_id) +} + +fn encode_cursor_at( + namespace: String, + last_id: String, + tail_length: Int, +) -> String { + encode_bytes(namespace <> ":" <> last_id <> "|" <> int.to_string(tail_length)) } -/// Decode a cursor scoped to `namespace`. Anything unparseable, malformed, -/// or scoped to a different namespace decodes to `None`. -pub fn decode_cursor(namespace: String, cursor: String) -> Option(String) { +fn encode_bytes(payload: String) -> String { + bit_array.base64_url_encode(bit_array.from_string(payload), False) +} + +/// Decode a cursor scoped to `namespace`. Unparseable, malformed or foreign +/// tokens decode to `None`. +pub fn decode_cursor(namespace: String, cursor: String) -> Option(ResumePoint) { case bit_array.base64_url_decode(cursor) { Error(_) -> None Ok(bytes) -> @@ -36,22 +49,29 @@ pub fn decode_cursor(namespace: String, cursor: String) -> Option(String) { Error(_) -> None Ok(decoded) -> case string.split_once(decoded, ":") { - Ok(#(ns, last_id)) if ns == namespace -> Some(last_id) + Ok(#(ns, rest)) if ns == namespace -> Some(parse_resume_point(rest)) _ -> None } } } } -/// Slice a page out of `items` (already sorted by the caller in the order -/// pagination should walk). -/// -/// No cursor and no limit means "return everything": the pre-pagination -/// behavior, so a caller that never adopts paging sees no change. Otherwise -/// the cursor resumes just after the matching id (an unrecognized or stale -/// cursor restarts from the top), and the page is capped at `limit` -/// (defaulting to, and ceilinged at, `default_limit`). Returns the page plus -/// the next cursor, `None` once there's nothing left to fetch. +// An unparseable tail length degrades rather than rejecting the cursor: the +// id half is still trustworthy. +fn parse_resume_point(rest: String) -> ResumePoint { + case string.split_once(rest, "|") { + Error(_) -> ResumePoint(rest, None) + Ok(#(last_id, tail)) -> + case int.parse(tail) { + Ok(n) -> ResumePoint(last_id, Some(n)) + Error(_) -> ResumePoint(last_id, None) + } + } +} + +/// Slice a page out of `items`, already sorted by the caller, capped at +/// `limit`. No cursor and no limit returns everything. Returns the page plus +/// the cursor for the next one, `None` once nothing is left. pub fn page( items: List(a), id_of: fn(a) -> String, @@ -68,12 +88,15 @@ pub fn page( } let capped = clamp_limit(limit) let taken = list.take(remaining, capped) - let next_cursor = case list.length(remaining) > capped { + let tail_length = list.length(remaining) - capped + let next_cursor = case tail_length > 0 { False -> None True -> taken |> list.last - |> result.map(fn(last) { encode_cursor(namespace, id_of(last)) }) + |> result.map(fn(last) { + encode_cursor_at(namespace, id_of(last), tail_length) + }) |> option.from_result } #(taken, next_cursor) @@ -81,9 +104,6 @@ pub fn page( } } -/// Everything in `items` after the element whose id matches the decoded -/// cursor; the full list when the cursor doesn't decode or its id isn't -/// found (stale/garbage cursors restart rather than error). fn resume_after( items: List(a), id_of: fn(a) -> String, @@ -92,11 +112,27 @@ fn resume_after( ) -> List(a) { case decode_cursor(namespace, token) { None -> items - Some(last_id) -> + Some(ResumePoint(last_id, tail_length)) -> case list.split_while(items, fn(i) { id_of(i) != last_id }) { #(_, [_found, ..rest]) -> rest - #(_, []) -> items + #(_, []) -> resume_by_tail_length(items, tail_length) + } + } +} + +// Keeping the last `tail_length` items over-covers once rows are deleted (a +// row is reseen) and under-covers once rows are inserted after the anchor (a +// row is lost). +fn resume_by_tail_length(items: List(a), tail_length: Option(Int)) -> List(a) { + case tail_length { + None -> items + Some(n) -> { + let total = list.length(items) + case n >= total { + True -> items + False -> list.drop(items, total - n) } + } } } diff --git a/server/test/pagination_test.gleam b/server/test/pagination_test.gleam index 564d67c..c17684a 100644 --- a/server/test/pagination_test.gleam +++ b/server/test/pagination_test.gleam @@ -12,8 +12,7 @@ fn ids(count: Int) -> List(String) { |> list.index_map(fn(_, i) { "item-" <> zero_padded(i + 1) }) } -// Zero-padded so string.compare sorts the same as numeric order, matching -// how entry ids (TIDs) sort lexically. +// Zero-padded so string.compare sorts numerically, as TID entry ids do. fn zero_padded(n: Int) -> String { string.pad_start(int.to_string(n), 3, "0") } @@ -24,7 +23,8 @@ fn identity(s: String) -> String { pub fn decode_cursor_roundtrips_an_encoded_cursor_test() { let token = pagination.encode_cursor("owned", "item-005") - assert pagination.decode_cursor("owned", token) == Some("item-005") + assert pagination.decode_cursor("owned", token) + == Some(pagination.ResumePoint("item-005", None)) } pub fn decode_cursor_rejects_malformed_or_mismatched_tokens_test() { @@ -64,17 +64,21 @@ pub fn page_past_the_end_of_the_list_is_empty_with_no_next_cursor_test() { pub fn page_walks_across_pages_by_cursor_test() { let items = ids(5) [ - #(None, ["item-001", "item-002"], Some("item-002")), - #(Some("item-002"), ["item-003", "item-004"], Some("item-004")), + #(None, ["item-001", "item-002"], Some(#("item-002", 3))), + #(Some("item-002"), ["item-003", "item-004"], Some(#("item-004", 1))), #(Some("item-004"), ["item-005"], None), ] |> list.each(fn(row) { - let #(cursor_id, expected_page, next_id) = row + let #(cursor_id, expected_page, expected_next) = row let cursor = option.map(cursor_id, pagination.encode_cursor("owned", _)) let #(page, next) = pagination.page(items, identity, "owned", cursor, Some(2)) assert page == expected_page - assert next == option.map(next_id, pagination.encode_cursor("owned", _)) + assert option.map(next, pagination.decode_cursor("owned", _)) + == option.map(expected_next, fn(anchor) { + let #(id, tail_length) = anchor + Some(pagination.ResumePoint(id, Some(tail_length))) + }) }) } @@ -99,3 +103,51 @@ pub fn page_with_only_a_cursor_and_no_limit_uses_the_default_limit_test() { assert list.length(page) == pagination.default_limit assert next != None } + +pub fn a_cursor_minted_by_page_resumes_by_position_when_its_anchor_vanishes_test() { + let items = ids(6) + let assert #(_first_page, Some(cursor)) = + pagination.page(items, identity, "owned", None, Some(2)) + as "cursor minted for a live list should carry a resume position" + + let items_without_anchor = list.filter(items, fn(id) { id != "item-002" }) + let #(page, next) = + pagination.page( + items_without_anchor, + identity, + "owned", + Some(cursor), + Some(2), + ) + assert page == ["item-003", "item-004"] + assert next != None +} + +pub fn resuming_by_position_never_truncates_the_tail_even_if_more_than_the_anchor_vanished_test() { + let items = ids(6) + let assert #(_first_page, Some(cursor)) = + pagination.page(items, identity, "owned", None, Some(2)) + as "cursor minted for a live list should carry a resume position" + + // Fewer rows survive than the recorded tail length: nothing safe to skip. + let heavily_pruned = ["item-004", "item-005", "item-006"] + let #(page, _next) = + pagination.page(heavily_pruned, identity, "owned", Some(cursor), Some(2)) + assert page == ["item-004", "item-005"] +} + +pub fn an_old_format_cursor_with_a_missing_anchor_still_restarts_from_the_top_test() { + // Cursors already held by clients carry no tail length. + let items = ids(5) + let old_style = pagination.encode_cursor("owned", "item-002") + let items_without_anchor = list.filter(items, fn(id) { id != "item-002" }) + let #(page, _next) = + pagination.page( + items_without_anchor, + identity, + "owned", + Some(old_style), + Some(2), + ) + assert page == ["item-001", "item-003"] +} -- 2.51.2 From 1871a659260f885ffba4cc882a69fd37cbc23aac Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Tue, 11 Aug 2026 09:57:54 +0200 Subject: [PATCH 13/16] fix: fail connection page reads instead of serving an empty last page --- server/src/crate_server/db.gleam | 19 ++++++++++-- server/src/crate_server/follow_index.gleam | 19 ++++++++---- .../crate_server/follow_index_postgres.gleam | 9 +++--- .../crate_server/handlers/connections.gleam | 25 ++++++++++++++-- server/src/crate_server/parallel.gleam | 11 ++++++- server/test/connections_handler_test.gleam | 30 +++++++++++++++++++ server/test/follow_index_postgres_test.gleam | 14 ++++----- server/test/follow_index_test.gleam | 10 +++---- 8 files changed, 110 insertions(+), 27 deletions(-) diff --git a/server/src/crate_server/db.gleam b/server/src/crate_server/db.gleam index 06615bb..05ab191 100644 --- a/server/src/crate_server/db.gleam +++ b/server/src/crate_server/db.gleam @@ -1,7 +1,8 @@ //// The query shapes every Postgres store backend was spelling out by hand. -//// All of them degrade instead of failing the caller: these stores back -//// derived, rebuildable projections, so a failed read is a stale page and a -//// failed write is a row the next reconcile pass restores. +//// Most degrade instead of failing the caller: these stores back derived, +//// rebuildable projections, so a failed read is a stale page and a failed +//// write is a row the next reconcile pass restores. `try_all` is the +//// exception, for reads where empty and unreadable are not the same answer. import gleam/dynamic/decode.{type Decoder} import gleam/option.{type Option, None, Some} @@ -35,6 +36,18 @@ pub fn all(query: pog.Query(a), conn: pog.Connection) -> List(a) { } } +/// `all` for a read whose caller must tell "no rows" apart from "no answer", +/// a paged read being the usual case. +pub fn try_all( + query: pog.Query(a), + conn: pog.Connection, +) -> Result(List(a), Nil) { + case pog.execute(query, conn) { + Ok(pog.Returned(rows:, ..)) -> Ok(rows) + Error(_) -> Error(Nil) + } +} + pub fn first(query: pog.Query(a), conn: pog.Connection) -> Option(a) { case pog.execute(query, conn) { Ok(pog.Returned(rows: [row, ..], ..)) -> Some(row) diff --git a/server/src/crate_server/follow_index.gleam b/server/src/crate_server/follow_index.gleam index ea71727..636d4c0 100644 --- a/server/src/crate_server/follow_index.gleam +++ b/server/src/crate_server/follow_index.gleam @@ -34,11 +34,16 @@ pub type EdgeOps { /// The dids `did` follows. Empty is ambiguous alone; pair with /// `SeenOps.has_seen`. following: fn(String) -> List(String), - following_page: fn(String, Option(String), Int) -> List(String), + /// One page of `following`. Errors rather than degrading to empty: a + /// paged reader reads an empty page as the end of the list, so a failed + /// read served as `[]` is indistinguishable from an authoritative answer. + following_page: fn(String, Option(String), Int) -> + Result(List(String), String), /// The dids that follow `did`. This reverse lookup is appview-derived and /// may be incomplete until every known actor has been backfilled. followers: fn(String) -> List(String), - followers_page: fn(String, Option(String), Int) -> List(String), + followers_page: fn(String, Option(String), Int) -> + Result(List(String), String), following_among: fn(String, List(String)) -> List(String), followers_among: fn(String, List(String)) -> List(String), /// The unfollow path's lookup, served without a live `listRecords`. @@ -68,6 +73,8 @@ pub type Store { Store(edges: EdgeOps, seen: SeenOps) } +const unavailable = "follow index unavailable" + type State { State(follows: Dict(String, Follow), seen: Set(String)) } @@ -104,11 +111,13 @@ pub fn start() -> Result(Store, actor.StartError) { delete_for_did: fn(did) { process.send(subject, DeleteForDid(did)) }, following: fn(did) { parallel.ask(subject, Following(did, _), or: []) }, following_page: fn(did, after, limit) { - parallel.ask(subject, FollowingPage(did, after, limit, _), or: []) + parallel.try_ask(subject, FollowingPage(did, after, limit, _)) + |> result.replace_error(unavailable) }, followers: fn(did) { parallel.ask(subject, Followers(did, _), or: []) }, followers_page: fn(did, after, limit) { - parallel.ask(subject, FollowersPage(did, after, limit, _), or: []) + parallel.try_ask(subject, FollowersPage(did, after, limit, _)) + |> result.replace_error(unavailable) }, following_among: fn(did, candidates) { parallel.ask(subject, FollowingAmong(did, candidates, _), or: []) @@ -126,7 +135,7 @@ pub fn start() -> Result(Store, actor.StartError) { parallel.ask( subject, ReplaceForDid(did, follows, _), - or: Error("follow index unavailable"), + or: Error(unavailable), ) }, count: fn() { parallel.ask(subject, Count, or: 0) }, diff --git a/server/src/crate_server/follow_index_postgres.gleam b/server/src/crate_server/follow_index_postgres.gleam index f742b70..16454b5 100644 --- a/server/src/crate_server/follow_index_postgres.gleam +++ b/server/src/crate_server/follow_index_postgres.gleam @@ -167,7 +167,7 @@ fn following_page( did: String, after: Option(String), limit: Int, -) -> List(String) { +) -> Result(List(String), String) { connection_page(conn, "did", "subject", did, after, limit) } @@ -259,7 +259,7 @@ fn followers_page( subject: String, after: Option(String), limit: Int, -) -> List(String) { +) -> Result(List(String), String) { connection_page(conn, "subject", "did", subject, after, limit) } @@ -270,7 +270,7 @@ fn connection_page( scope: String, after: Option(String), limit: Int, -) -> List(String) { +) -> Result(List(String), String) { let row = { use value <- decode.field("value", decode.string) decode.success(value) @@ -284,7 +284,8 @@ fn connection_page( |> pog.parameter(pog.nullable(pog.text, after)) |> pog.parameter(pog.int(limit)) |> pog.returning(row) - |> db.all(conn) + |> db.try_all(conn) + |> result.replace_error("follow_edges page read failed") } fn following_among( diff --git a/server/src/crate_server/handlers/connections.gleam b/server/src/crate_server/handlers/connections.gleam index cc2af31..1741bfd 100644 --- a/server/src/crate_server/handlers/connections.gleam +++ b/server/src/crate_server/handlers/connections.gleam @@ -121,7 +121,7 @@ fn page_dids( direction: Direction, after: Option(String), limit: Int, -) -> List(String) { +) -> Result(List(String), String) { case direction { Following -> ctx.follow_index.edges.following_page(viewer_did, after, limit) Followers -> ctx.follow_index.edges.followers_page(viewer_did, after, limit) @@ -160,7 +160,15 @@ fn respond_page( cursor |> option.then(pagination.decode_cursor(namespace, _)) |> option.map(fn(point: pagination.ResumePoint) { point.last_id }) - let rows = page_dids(ctx, session_did, direction, after, limit + 1) + // An unreadable index must not reach the client as an empty final page: it + // would read as an authoritative end of the list. + use rows <- unless_unreadable(page_dids( + ctx, + session_did, + direction, + after, + limit + 1, + )) let page_dids = list.take(rows, limit) let related = case direction, following_complete { Following, _ -> @@ -211,6 +219,19 @@ fn respond_page( |> wisp.json_response(200) } +fn unless_unreadable( + read: Result(a, String), + continue: fn(a) -> Response, +) -> Response { + case read { + Ok(rows) -> continue(rows) + Error(message) -> { + wisp.log_warning("connections: " <> message) + error_json(503, "could not read your connections") + } + } +} + fn cursor_namespace(direction: Direction, viewer_did: String) -> String { let prefix = case direction { Following -> "connections-following-" diff --git a/server/src/crate_server/parallel.gleam b/server/src/crate_server/parallel.gleam index 675b886..e8d813c 100644 --- a/server/src/crate_server/parallel.gleam +++ b/server/src/crate_server/parallel.gleam @@ -39,7 +39,16 @@ pub fn ask( make_request: fn(Subject(reply)) -> msg, or default: reply, ) -> reply { - try_call(subject, ask_timeout, make_request) |> result.unwrap(default) + try_ask(subject, make_request) |> result.unwrap(default) +} + +/// `ask` for a read whose caller must tell "nothing there" apart from "could +/// not look". +pub fn try_ask( + subject: Subject(msg), + make_request: fn(Subject(reply)) -> msg, +) -> Result(reply, Nil) { + try_call(subject, ask_timeout, make_request) } type Event(b) { diff --git a/server/test/connections_handler_test.gleam b/server/test/connections_handler_test.gleam index 72113f8..d7779c4 100644 --- a/server/test/connections_handler_test.gleam +++ b/server/test/connections_handler_test.gleam @@ -264,3 +264,33 @@ pub fn a_connection_cursor_paginates_and_is_viewer_scoped_test() { assert support.field_nested(body, ["items"], ["did"]) == Ok(expected) }) } + +pub fn an_unreadable_connection_page_is_a_503_not_an_empty_final_page_test() { + let base = support.fresh_follow_index() + seed_followers(base, "did:plc:me", ["did:plc:a"]) + base.seen.mark_seen("did:plc:me") + let wedged = + follow_index.Store( + ..base, + edges: follow_index.EdgeOps( + ..base.edges, + following_page: fn(_did, _after, _limit) { Error("read failed") }, + followers_page: fn(_did, _after, _limit) { Error("read failed") }, + ), + ) + + ["following", "followers"] + |> list.each(fn(direction) { + let resp = + respond( + wedged, + support.unreachable_client(), + auth_session(), + "graph.listConnections?direction=" <> direction, + ) + assert resp.status == 503 + let body = simulate.read_body(resp) + assert support.field_present(body, ["items"]) == False + assert support.field_present(body, ["cursor"]) == False + }) +} diff --git a/server/test/follow_index_postgres_test.gleam b/server/test/follow_index_postgres_test.gleam index 4eca9db..6952379 100644 --- a/server/test/follow_index_postgres_test.gleam +++ b/server/test/follow_index_postgres_test.gleam @@ -44,10 +44,10 @@ pub fn follow_round_trip_test() { == ["did:plc:subj-a", "did:plc:subj-b"] store.edges.upsert(follow("did:plc:other", did, "fr3")) assert store.edges.followers(did) == ["did:plc:other"] - assert store.edges.following_page(did, None, 1) == ["did:plc:subj-a"] + assert store.edges.following_page(did, None, 1) == Ok(["did:plc:subj-a"]) assert store.edges.following_page(did, Some("did:plc:subj-a"), 1) - == ["did:plc:subj-b"] - assert store.edges.followers_page(did, None, 1) == ["did:plc:other"] + == Ok(["did:plc:subj-b"]) + assert store.edges.followers_page(did, None, 1) == Ok(["did:plc:other"]) assert store.edges.following_among(did, ["did:plc:subj-b"]) == ["did:plc:subj-b"] assert store.edges.followers_among(did, ["did:plc:other"]) @@ -82,7 +82,7 @@ pub fn connection_pages_resume_past_a_deleted_anchor_test() { store.edges.upsert(follow(follower, did, "fa4")) assert store.edges.following_page(did, Some("did:plc:anchor-a"), 2) - == ["did:plc:anchor-b", "did:plc:anchor-c"] + == Ok(["did:plc:anchor-b", "did:plc:anchor-c"]) // The anchor is unfollowed between pages: the next page must resume past it // rather than restart from the top or collapse to nothing. @@ -90,10 +90,10 @@ pub fn connection_pages_resume_past_a_deleted_anchor_test() { "at://" <> did <> "/dev.mokkenstorm.crate.graph.follow/fa2", ) assert store.edges.following_page(did, Some("did:plc:anchor-b"), 2) - == ["did:plc:anchor-c"] - assert store.edges.following_page(did, Some("did:plc:zzz"), 2) == [] + == Ok(["did:plc:anchor-c"]) + assert store.edges.following_page(did, Some("did:plc:zzz"), 2) == Ok([]) assert store.edges.followers_page(did, Some("did:plc:absent"), 2) - == [follower] + == Ok([follower]) store.edges.delete_for_did(did) store.edges.delete_for_did(follower) diff --git a/server/test/follow_index_test.gleam b/server/test/follow_index_test.gleam index 14f6c41..bd72181 100644 --- a/server/test/follow_index_test.gleam +++ b/server/test/follow_index_test.gleam @@ -90,15 +90,15 @@ pub fn connection_pages_are_distinct_bounded_and_resume_after_a_subject_test() { store.edges.upsert(follow("did:z", "did:a", "f5")) store.edges.upsert(follow("did:y", "did:a", "f6")) - assert store.edges.following_page("did:a", None, 2) == ["did:b", "did:c"] - assert store.edges.following_page("did:a", Some("did:c"), 2) == ["did:d"] - assert store.edges.followers_page("did:a", None, 1) == ["did:y"] + assert store.edges.following_page("did:a", None, 2) == Ok(["did:b", "did:c"]) + assert store.edges.following_page("did:a", Some("did:c"), 2) == Ok(["did:d"]) + assert store.edges.followers_page("did:a", None, 1) == Ok(["did:y"]) // A cursor whose anchor was unfollowed between pages resumes at the next // surviving subject rather than restarting, so page 2 never re-serves page 1. store.edges.delete("at://did:a/dev.mokkenstorm.crate.graph.follow/f3") - assert store.edges.following_page("did:a", Some("did:c"), 2) == ["did:d"] - assert store.edges.following_page("did:a", Some("did:zzz"), 2) == [] + assert store.edges.following_page("did:a", Some("did:c"), 2) == Ok(["did:d"]) + assert store.edges.following_page("did:a", Some("did:zzz"), 2) == Ok([]) } pub fn relationship_intersections_only_return_matching_candidates_test() { -- 2.51.2 From da56974a224d97b4efbc669de8e42bf3ef96f356 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Tue, 11 Aug 2026 10:04:18 +0200 Subject: [PATCH 14/16] fix: report only a missing follow scope as a 403 --- server/src/crate_server/context.gleam | 12 ++++++- server/src/crate_server/handlers/graph.gleam | 13 ++++---- server/test/graph_handler_test.gleam | 35 +++++++++++++++++++- web/src/crate_web/update/common.gleam | 7 ++-- web/test/connections_test.gleam | 28 ++++++++++++++++ 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/server/src/crate_server/context.gleam b/server/src/crate_server/context.gleam index 8c9a8e0..5297c7b 100644 --- a/server/src/crate_server/context.gleam +++ b/server/src/crate_server/context.gleam @@ -82,8 +82,18 @@ pub fn optional_field( /// XRPC error body: a PascalCase `error` name derived from the status plus the /// human-readable `message`. pub fn error_json(status: Int, message: String) -> Response { + named_error_json(status, error_name(status), message) +} + +/// `error_json` for a failure a client must branch on: the status alone does +/// not say which of several conditions produced it. +pub fn named_error_json( + status: Int, + error: String, + message: String, +) -> Response { json.object([ - #("error", json.string(error_name(status))), + #("error", json.string(error)), #("message", json.string(message)), ]) |> json.to_string diff --git a/server/src/crate_server/handlers/graph.gleam b/server/src/crate_server/handlers/graph.gleam index 77e2780..b0b5ada 100644 --- a/server/src/crate_server/handlers/graph.gleam +++ b/server/src/crate_server/handlers/graph.gleam @@ -8,7 +8,7 @@ import atproto_core/xrpc as core_xrpc import crate/gen/graph/follow.{type GraphFollow} import crate/storage.{type StoredItem} import crate_server/context.{ - type Context, error_json, require_session, with_pds_client, + type Context, error_json, named_error_json, require_session, with_pds_client, } import crate_server/follow_index import crate_server/graph_follows @@ -72,16 +72,14 @@ fn follow_response(uri: String) -> Response { } /// Existing sessions may not have the graph.follow scope that current OAuth -/// requests ask for. Preserve the authorization signal so the browser can -/// offer an explicit reauthorization instead of hiding it as an upstream -/// outage. +/// requests ask for. Only that condition is reported as a 403: a suspended or +/// taken-down account is also a PDS 403, and offering it reauthorization is a +/// loop the user cannot leave. fn pds_error(error: XrpcError, fallback: String) -> Response { case error { core_xrpc.BadStatus(_, Some(code), _, _) if code == "InsufficientScope" || code == "insufficient_scope" -> insufficient_scope_response() - core_xrpc.BadStatus(status, _, _, _) if status == 403 -> - insufficient_scope_response() core_xrpc.BadStatus(status, _, _, _) if status == 401 -> error_json(401, "your session expired while writing the follow record") _ -> error_json(502, fallback) @@ -89,8 +87,9 @@ fn pds_error(error: XrpcError, fallback: String) -> Response { } fn insufficient_scope_response() -> Response { - error_json( + named_error_json( 403, + "InsufficientScope", "your session needs permission to follow collectors; sign in again to approve it", ) } diff --git a/server/test/graph_handler_test.gleam b/server/test/graph_handler_test.gleam index f3de9e6..2b71c63 100644 --- a/server/test/graph_handler_test.gleam +++ b/server/test/graph_handler_test.gleam @@ -96,6 +96,21 @@ fn network_client(list_body: String, create_body: String) -> xrpc.Client { }) } +fn forbidden_client(error_code: String) -> xrpc.Client { + xrpc.Client(send: fn(req) { + case req.path { + "/xrpc/com.atproto.repo.listRecords" -> ok_response(list_records_body([])) + "/xrpc/com.atproto.repo.putRecord" -> + Ok(response.Response( + 403, + [], + bit_array.from_string("{\"error\":\"" <> error_code <> "\"}"), + )) + _ -> panic as { "unexpected path: " <> req.path } + } + }) +} + fn missing_scope_client() -> xrpc.Client { xrpc.Client(send: fn(req) { case req.path { @@ -272,13 +287,31 @@ pub fn follow_scope_failure_is_exposed_as_a_reauthorization_signal_test() { ) assert resp.status == 403 let body = simulate.read_body(resp) - assert support.field_string(body, ["error"]) == Ok("Forbidden") + assert support.field_string(body, ["error"]) == Ok("InsufficientScope") assert support.field_string(body, ["message"]) == Ok( "your session needs permission to follow collectors; sign in again to approve it", ) } +/// A suspended or taken-down account is a PDS 403 too. Answering it with the +/// reauthorization prompt sends the user round a loop that cannot help them. +pub fn a_non_scope_pds_403_is_an_upstream_failure_not_a_scope_prompt_test() { + ["AccountTakedown", "AccountDeactivated", "Forbidden"] + |> list.each(fn(code) { + let resp = + call( + graph.follow, + "graph.followUser", + forbidden_client(code), + support.fresh_follow_index(), + ) + assert resp.status == 502 + assert support.field_string(simulate.read_body(resp), ["error"]) + == Ok("UpstreamFailure") + }) +} + pub fn follow_mirrors_the_written_record_into_the_index_test() { let follows = seeded_index([]) assert call(graph.follow, "graph.followUser", no_list_client(), follows).status diff --git a/web/src/crate_web/update/common.gleam b/web/src/crate_web/update/common.gleam index dd0fa64..5457f4a 100644 --- a/web/src/crate_web/update/common.gleam +++ b/web/src/crate_web/update/common.gleam @@ -49,15 +49,16 @@ pub fn write_error( } } -/// Follow writes use a distinct 403 response when an existing OAuth session -/// lacks the graph.follow scope. Other writes keep the generic error path. +/// Follow writes answer `InsufficientScope` when an existing OAuth session +/// lacks the graph.follow scope. Every other 403 (a suspended or taken-down +/// account, say) takes the generic path: reauthorizing would not fix it. pub fn follow_write_error( model: Model, error: msg.ApiError, fallback: String, ) -> #(Model, Effect(Msg)) { case error { - xrpc.BadStatus(status: 403, ..) -> #( + xrpc.BadStatus(status: 403, error: Some("InsufficientScope"), ..) -> #( Model( ..model, busy: False, diff --git a/web/test/connections_test.gleam b/web/test/connections_test.gleam index f67f751..2699940 100644 --- a/web/test/connections_test.gleam +++ b/web/test/connections_test.gleam @@ -214,6 +214,34 @@ pub fn stale_follow_completion_after_same_route_reload_is_ignored_test() { assert after_stale.connection_pending == None } +/// A suspended or taken-down account is a 403 too, and reauthorizing does +/// nothing for it: only the scope code earns the reauthorization prompt. +pub fn a_non_scope_403_does_not_offer_reauthorization_test() { + ["AccountTakedown", "Forbidden", "UpstreamFailure"] + |> list.each(fn(code) { + let #(pending, _) = + update( + loaded_follow_state(False), + ToggleConnectionFollow("did:bob", False), + ) + let assert Some(request) = pending.connection_pending + let #(updated, _) = + update( + pending, + GotConnectionFollow( + request, + Error(xrpc.BadStatus(403, Some(code), None, "")), + ), + ) + assert updated.notice != None + assert case updated.notice { + Some(ReauthorizeNotice(_)) -> True + _ -> False + } + == False + }) +} + pub fn missing_follow_permission_offers_reauthorization_in_connections_test() { let #(pending, effect) = update(loaded_follow_state(False), ToggleConnectionFollow("did:bob", False)) -- 2.51.2 From f578dd71c0d26e7c64de642e97cc16881263ccc0 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Tue, 11 Aug 2026 10:04:50 +0200 Subject: [PATCH 15/16] test: drop a connections needle that every render satisfies --- web/test/connections_test.gleam | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/test/connections_test.gleam b/web/test/connections_test.gleam index 2699940..c7c3b8f 100644 --- a/web/test/connections_test.gleam +++ b/web/test/connections_test.gleam @@ -82,7 +82,7 @@ pub fn connection_states_render_distinctly_test() { True, ), True, - ["@bob.test", "Follow @bob.test", "FOLLOW", "aria-current=\"page\""], + ["@bob.test", "Follow @bob.test", "aria-current=\"page\""], [], ), #(ConnectionsLoading, False, ["LOADING CONNECTIONS"], ["RETRY"]), -- 2.51.2 From 7ea4474b114f778b1c4bf3d8e27420831cf48105 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Tue, 11 Aug 2026 10:05:53 +0200 Subject: [PATCH 16/16] refactor: collapse the follow backfill completeness branch --- server/src/crate_server/user_backfill.gleam | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/src/crate_server/user_backfill.gleam b/server/src/crate_server/user_backfill.gleam index 64f9740..4a7c4dc 100644 --- a/server/src/crate_server/user_backfill.gleam +++ b/server/src/crate_server/user_backfill.gleam @@ -126,10 +126,8 @@ fn backfill_follows( Ok(Nil) -> index.seen.mark_seen(user.did) Error(_) -> Nil } - False -> Nil - } - case complete { - True -> Nil + // A truncated run cannot replace: the rows it did not see would be + // deleted. It adds what it has and leaves the did unseen. False -> rows |> list.each(index.edges.upsert) } list.length(rows) -- 2.51.2