Added an events bus, and pushed OpportunitiesUpdated to WebSocket clients when the fetcher stores new opportunities

internal/events decouples the parts of JobSearch that cause events from
the parts that react to them. bus.go has Bus: Subscribe, and a synchronous
Publish that calls each handler in turn on the publisher's goroutine, so
handlers mustn't block (the hub doesn't: it queues per client). events.go
has the event types and their payloads, starting with OpportunitiesUpdated.

With fetcher.WithEvents, a save that stores new opportunities, or new
releases of stored ones, publishes OpportunitiesUpdated with their UUIDs and
counts. An opportunity whose row is only rewritten, as when it is fetched
again from the newest stored release, isn't counted as updated, and nothing
is published when nothing changed. store.SaveResult now reports
NewOpportunity for each opportunity saved.

job-search serve subscribes its WebSocket hub to the bus, so every client
gets {"type":"event","eventType":"OpportunitiesUpdated","payload":{"new":
[...],"updated":[...],"summary":{"new":n,"updated":n}}}. The Envelope gains
an eventType field, and Hub.PushEvent and Server.PushEvent send events.
This commit is contained in:
bwilliams committed 2026-10-16 11:59:26 +01:00
1 parent 284a589627
commit 6e101d2b4d
13 files changed
+448 -26

No files matched your search

+4 -3
View File
@@ -65,6 +65,8 @@ type SaveMultipleResult struct {
// SaveResult reports what SaveOpportunity changed.
type SaveResult struct {
// NewOpportunity is true if the opportunity was not already stored.
NewOpportunity bool
// OpportunityUpdated is true if the opportunity row and its classifications
// were written. It is false when every release in the save was older than the
// one the stored opportunity was last updated from.
@@ -344,7 +346,6 @@ func saveOpportunity(
) (SaveResult, bool, error) {
var (
result SaveResult
isNew bool
stored time.Time
)
err := tx.GetContext(
@@ -352,7 +353,7 @@ func saveOpportunity(
)
switch {
case errors.Is(err, sql.ErrNoRows):
isNew = true
result.NewOpportunity = true
result.OpportunityUpdated = true
case err != nil:
return SaveResult{}, false, fmt.Errorf(
@@ -380,7 +381,7 @@ func saveOpportunity(
}
}
return result, isNew, nil
return result, result.NewOpportunity, nil
}
// insertReleaseWithTags stores release and its tags unless it is already
+31
View File
@@ -871,3 +871,34 @@ func TestLatestReleaseDate(t *testing.T) {
t.Errorf("unknown source: %v, %v; want the zero time", latest, err)
}
}
// NewOpportunity is set only for an opportunity that wasn't already stored,
// by both SaveOpportunity and SaveOpportunities.
func TestSaveResultNewOpportunity(t *testing.T) {
s := openStore(t)
ctx := context.Background()
res := mustSave(t, s, opportunity("ocds-1", "First", 1, "r1"))
if !res.NewOpportunity {
t.Errorf("first save: NewOpportunity = false")
}
res = mustSave(t, s, opportunity("ocds-1", "First", 2, "r2"))
if res.NewOpportunity {
t.Errorf("second save: NewOpportunity = true")
}
total, err := s.SaveOpportunities(ctx, []*types.Opportunity{
opportunity("ocds-1", "First", 3, "r3"),
opportunity("ocds-2", "Second", 3, "r1"),
})
if err != nil {
t.Fatal(err)
}
results := total.OpportunityResults
if results[0].NewOpportunity || !results[1].NewOpportunity {
t.Errorf(
"SaveOpportunities results = %+v; want only the second new",
results,
)
}
}