Skip to content

feat(follows): team, player and match follows that download; editable web follows; per-league delete [FX-19] - #156

Merged
tunjayoff merged 7 commits into
mainfrom
feat/fx-19
Oct 6, 2026
Merged

tunjayoff merged 7 commits into
mainfrom
feat/fx-19

Conversation

@tunjayoff

@tunjayoff tunjayoff commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Item: FX-19 (new, not yet in docs/design/03-implementation-plan.md): the backend gaps of the first-time-user review of the web UI (problems 2, 3, 4, 9, 10 and "Missing functions"), with the owner decision of 2026-10-06: team, player and single-match follows work in 3.0.0 (name search and downloading their matches).

Behaviour change.

  • A follow added through the API or the web UI is always kept in the follows table (origin: "api") and every field stays editable. Before, without a config file, a tournament follow was written to config/leagues.txt and then shown locked.
  • Team, player and event follows now download their matches in ssc sync, the scheduler and POST /api/v1/jobs. Before, they were skipped, or answered 400 unsupported.
  • SofaScore search finds teams and players by name.
  • One league's stored data can be deleted.
  • /status says whether a request ever reached SofaScore.
  • Export files have readable names, and /exports lists the files written by ssc export.

Branch feat/fx-19 on origin/main at 6e25d29 (#153, FX-14a #154, FX-15 #155 and the dependency PRs #143 and #149 merged). Rebased on FX-15: its removal of SyncSpec.export stays (the new --follow spec and the new tests build specs without it), and SelectionPolicy keeps both FX-15's country and FX-19's via_events. docs/api/openapi-v1.json and frontend/src/api/v1/schema.ts were regenerated in every commit that changes a route, so each commit's generated files match its code.

What was done

Seven commits. Each one is green on its own (full suite per commit, see "How it was verified").

  1. feat(follows): web follows live in the follows table; a leagues.txt follow can be moved there.

    • FollowsService.add always writes an api row, with or without a config file.
    • config/leagues.txt stays a read-only legacy source:
      • its rows can change only sport;
      • PATCH {"origin": "api"} moves a row into the follows table (FollowsService.adopt). The new FollowStore.adopt changes the row's origin and keeps its fields and position, then the line is removed from the file and from the sport sidecar. The next mirror does not bring it back, because api outranks legacy.
    • The move is explicit; no rule moves rows on its own.
    • /status summary.tournaments[].followed reads the follows table (every origin).
  2. feat(search): POST /api/v1/tournaments/search takes kinds (tournament, team, player).

    • Tournaments alone keep /search/unique-tournaments/{q}. Any other choice asks /search/all?q=…&page=0, the endpoint of docs/all-sports/endpoints.csv with the research sample research/all_sports/samples/football/search-all__1.json. Either way it is one request per search.
    • Hits are typed by kind, with sport, country, a player's team and followed.
    • A 404 answer is an empty list.
  3. feat(sync): team, player and match follows download their matches (src/services/follow_sync.py, wired in SyncService).

    • Lists.

      • A team reads /team/{id}/events/next/0 and /team/{id}/events/last/{n}.
      • A player reads only /player/{id}/events/last/{n}; the catalog has no next page for players.
      • An event follow is the match itself.
    • Window. A team or player follow uses its seasons value as a window:

      seasons Window
      current matches that started in the last 365 days
      last:N the last N × 365 days
      all up to MAX_LAST_PAGES = 5 pages back
      season ids only matches of those seasons

      Reading back stops at hasNextPage: false or 404, at the page limit, or when a page's oldest match is older than the window. Upcoming matches are not cut by the time window.

    • Needs. The planner's compute_need, plus three rules:

      • a match the list shows as ended that the catalog does not have as ended: full;
      • an unknown match that has not ended: its /event only (one request, no slices);
      • an event follow whose stored match should have started and has not ended: full.
    • Data selection. Each match goes through the fetch pipeline with the follows' selection (P27 SelectionPolicy, narrowest follow wins). A player's matches take the player follow's selection as the last link of the chain (SelectionPolicy.with_follow_events / via_events), after the event, tournament and team follows.

    • Wiring.

      • A sync without a target downloads every enabled team, player and event follow: ssc sync, the scheduler, and POST /jobs {"kind": "sync"}.
      • follows=[…] accepts these kinds (was 400 unsupported).
      • ssc sync --follow KIND:ID (repeatable); --dry-run counts them.
      • GET /jobs?target= takes team: and player:.
      • Listings run at the end of the match-list phase, matches at the end of the details phase, both on the job's counters.
    • Failures and limits. An unreadable list is a failed listing (team_events / player_events) and the job ends partial. The shared request budget, the job's breaker and cancellation apply: requests use the job's request context, and lists use Client.get_sync.

  4. feat(maintenance): delete one league's stored data.

    • store.purge.tournament(id, season_id=) (new src/store/purge.py, Store.purge) runs under the maintenance lease, like Store.clear. It deletes:
      • the tournament's events in both layouts (v3 event directories with their history, legacy copies in match_details, and empty legacy containers);
      • the schedules (v3 season directory; legacy matches/<league>/<season>/ and that season's summary files);
      • for the whole tournament, the season list.
    • Afterwards it rebuilds the catalog in place.
    • Follows, the change log, the job history, backups, exports and team and player directories stay.
    • The new MaintenanceService.clear_tournament calls it. The clear job takes tournament_id and season_id.
    • DELETE /follows/{id}?delete_data=true starts that clear job first (lease), removes the follow, then runs the job.
  5. feat(status): connection state. The request layer reports every request's outcome: src/breaker.py report_ok and report_exception feed ConnectionState in src/bridge_health.py.

    • /status, /health and /status/check carry connection.state:
      • never_tried: no request has ended yet;
      • ok: SofaScore answered (200 or 404);
      • failed: 403, 429, 5xx, timeout, network or parse error.
    • Each answer also has the last success and failure times, the reason and the HTTP status.
    • A request the breaker held back counts for neither. The bridge state is unchanged.
  6. feat(exports): readable file names; files of ssc export are listed.

    • New export files are named <league or dataset>_<UTC date>_<last 8 of the job id>.<ext>, for example premier-league_2026-10-06_x7k2m9qa.csv. A raw export adds -raw and the 2.x wide CSV adds -wide.
    • GET /exports also lists the files in exports/ that no job wrote, with source: "file" and id file:<name>; they are downloadable. The listing and lookup are store.export.files() and file_path().
  7. feat(status): bridge times of every transport; the last connection check. Both were asked for by the coordinator after FX-14a (feat(web): newcomer pass — add-league entry points, plain words, help [FX-14a] #154).

    • /status and /health bridge.last_success_at / last_failure_at are the last answered and the last failed request of any transport. Curl answers never reached the browser bridge, so after a successful download FX-14a's UI still said "not tried yet". FX-14a's connectionState works unchanged with these fields.
    • The bridge's state, series and last_error still count its own refusals. The circuit breaker and src/web/upstream.py keep reading the bridge's own times (bridge_health.snapshot()); the API uses public_snapshot().
    • connection.last_check: {at, ok, reason} is the last POST /status/check of this server, so the UI need not remember it per tab.

Every route, body and field change (for FX-14b)

No new path. Component names are kept where the frontend imports them (TournamentHit, FollowRecord, ExportRecord).

POST /api/v1/follows: always an api row, so writable lists every field. slices, seasons, live and enabled are accepted without a config file; this was 400 unsupported.

PATCH /api/v1/follows/{id}: new field origin: "api" | null.

  • On a legacy follow it moves the row into the follows table. The row leaves config/leagues.txt and its sidecar. The other fields of the same request are applied after the move.
  • On an api follow it changes nothing.
  • On a config follow it is 409 follow_managed.
  • Any other value is 422.
  • A legacy follow's writable is now ["sport", "origin"].

DELETE /api/v1/follows/{id}: new query delete_data (default false).

  • The response model is now FollowRemoveResponse, with data: RemovedFollow = FollowRecord + clear_job: Job | null.
  • With delete_data=true on a tournament follow:
    1. a clear job with tournament_id takes the maintenance lease (409 job_running / data_operation_running / instance_running if it cannot);
    2. the follow is removed;
    3. the job runs, and data.clear_job is that job.
  • delete_data on another kind is 400 invalid_request with details: {field: "delete_data", kind}.
  • delete_data on a config follow is 409 follow_managed.

POST /api/v1/tournaments/search:

  • Body: new kinds: ("tournament" | "team" | "player")[] (1 to 3, default ["tournament"]).
  • TournamentHit gains:
    • kind (default tournament);
    • country: {code, name} | null;
    • team: {id, name} | null (players).
  • category stays required: for a team or a player only category.country_code is set.
  • followed is per kind.
  • A 404 from SofaScore is now [] (was 502 upstream_error).

POST /api/v1/jobs:

  • sync follows:
    • takes team:<id>, player:<id> and event:<id> (was 400 with details.unsupported);
    • an unknown follow is still 404;
    • a disabled one is skipped with the log code sync_follow_skipped.
  • A sync without a target also downloads every enabled team, player and event follow.
  • clear: new tournament_id and season_id.
    • season_id without tournament_id → 400 invalid_request with details.fields.
    • tournament_id with a scope other than all → 400 invalid_request.
    • Result: clear: {scopes: ["tournament"], tournament_id, season_id, events, event_dirs, listings, catalog_rebuilt}.
  • Sync result: failed_listings[].kind can be team_events or player_events; league_id is then the team's or the player's id.
  • New job log codes, whose texts belong to FX-14b:
    • sync_follow_listing {follow, name};
    • sync_follow_listing_failed {follow, name, reason};
    • sync_follow_details {count}.
  • sync_follow_skipped is also used for team, player and event follows.

GET /api/v1/jobs: target also takes team:<id> and player:<id>. A follow event:<id> and a clear's tournament_id are targets too.

GET /api/v1/status, GET /api/v1/health, POST /api/v1/status/check:

  • New required connection: ConnectionStatus with state (never_tried | ok | failed), last_success_at, last_failure_at, last_failure_reason, last_failure_status and last_check: {at, ok, reason} | null.
  • bridge.last_success_at and bridge.last_failure_at now cover every transport, not only the browser bridge. bridge.state is unchanged. In /status, summary.tournaments[].followed = a follow of any origin names the tournament (was "a configured league").

GET /api/v1/exports:

  • ExportRecord gains source: "job" | "file"; job_id is now nullable (null for a file).
  • The list includes files of exports/ that no job wrote, with id file:<name>, state: succeeded, dataset and format read from the name (else unknown), bytes and created_at from the file.
  • Merged newest first. cursor may be up to 256 characters.

GET /api/v1/exports/{export_id}/download: export_id may be file:<name>, up to 256 characters. The download's file name is the stored file name, <label>_<date>_<id8>.<ext> (was sofascore-export-<job id>.<ext>).

CLI:

  • ssc sync --follow KIND:ID (repeatable). Not with --tournament or --only events (exit 2).
  • ssc sync --dry-run JSON gains follows: {team, player, event}; text output gains a line.
  • New locale keys ssc_help_sync_follow and ssc_plan_follows (en and tr).

Store API (snapshots under tests/fixtures/store_api/ regenerated):

  • FollowStore.adopt(kind, entity_id, *, origin="api");
  • Store.purge (Purger.tournament(tournament_id, *, season_id=None) -> TournamentClearReport);
  • Exporter.files() -> List[ExportFile] and Exporter.file_path(name);
  • new exports Purger, TournamentClearReport and ExportFile in src.store.

How it was verified

  • Full suite with STORE_SHADOW_CHECK=1 (home-disk TMPDIR and --basetemp), in the foreground:

    Run Passed Skipped Deselected Collected
    origin/main 6e25d29 9,671 33 20 9,704
    this branch d1d84c4 9,735 33 20 9,768 (+64)

    Before the rebase on FX-15, each of the seven commits passed the full suite on its own (on de83879; 9,676 / 9,686 / 9,711 / 9,726 / 9,731 / 9,736 / 9,737 passed).

  • tests/test_api_v1_data_jobs.py::test_a_restore_replaces_the_data_and_leaves_a_finished_job failed once in a combined run before the rebase. On the rebased branch it passed 30 of 30 runs alone, 30 of 30 runs with its whole file, and 5 of 5 runs of the combined subset that failed once. No flake was reproduced.

  • ruff check . clean.

  • python -m src.web.openapi --check clean.

  • node scripts/gen-api-types.mjs --check clean.

  • Frontend npm run build (vue-tsc) passes, and so does vitest run (329 passed, on main with FX-14a). The frontend sources are unchanged apart from schema.ts.

  • No request to any SofaScore host, no browser.

New tests:

  • tests/test_fx19_search.py: one request per search, typed hits from the research sample, kinds and sport filters, 404 as empty, typed errors.
  • tests/test_fx19_follow_sync.py:
    • paths against the catalog samples;
    • parsing the research samples;
    • the window table;
    • team and player lists, the page limit and the stop rules;
    • the need rules;
    • a team follow downloads its four matches (finished in full, upcoming and live /event only), and the second sync requests only the lists;
    • player selection, and the narrower follow winning;
    • event follow;
    • a sync without a target, and the targets that exclude these follows;
    • skipped follows;
    • failed list → partial;
    • cancel;
    • breaker;
    • POST /jobs end to end with the real context;
    • ssc sync --follow and --dry-run.
  • tests/test_fx19_clear.py: season and whole-tournament purge in both layouts, the lease, the clear job's checks and result, and DELETE /follows?delete_data=true with its refusals.
  • tests/test_fx19_connection.py: the state table, request-layer outcomes (200, 404, 403), the breaker not counting, and the connection check.
  • tests/test_fx19_exports.py: labels and names, the job's readable file and download name, ssc export files listed and served, traversal refused, pagination, and the Store listing.
  • Extended: tests/test_api_v1_follows.py (moving a leagues.txt follow) and tests/test_store_follows.py (FollowStore.adopt).

Existing tests changed, and why

  • tests/test_api_v1_follows.py:
    • a legacy follow's writable gains origin;
    • test_a_tournament_follow_goes_to_the_league_file_without_a_config_file and test_the_league_file_keeps_only_name_and_sport are replaced by tests of the new rule (an api row with every field);
    • test_removing_a_league_file_follow_edits_both_files puts the line into leagues.txt itself (the API no longer writes it);
    • the search hit carries kind, country and team.
  • tests/test_api_v1_status.py::test_status_lists_followed_tournaments_without_matches: followed comes from the follows table, so the test adds a follow instead of patching ConfigManager.get_leagues.
  • tests/test_slice_selection.py::test_a_league_file_follow_cannot_hold_a_selection:
    • a new tournament follow holds a selection without a config file (201);
    • a legacy follow still refuses one (400);
    • after origin: "api" it holds one.
  • tests/test_fx13_api_jobs.py:
    • follows: ["team:42"] is accepted (was 400);
    • target=team:1 is valid, so the 422 case uses league:1.
  • tests/test_api_v1_jobs.py: the key lists of /health and /status gain connection, and /health's bridge equals bridge_health.public_snapshot().
  • tests/test_cli_data_commands.py::test_the_new_commands_are_registered_and_described: sync options gain --follow.
  • tests/test_api_v1_data_jobs.py:
    • the export file is the readable name, and the download's file name is that name;
    • the "deleted file" test removes the readable file.

Files outside ownership

FX-19 has no brief yet, so nothing is owned formally. Files that other items of this batch own:

  • src/services/sync.py (FX-15): the follow steps (_other_follows, _follow_listings, _follow_details), a skip in _leagues, and the FailedListing docstring.
  • src/store/api.py (FX-15): one import and the attribute line self.purge = Purger(self), the rule-2 exception.
  • src/store/__init__.py: one contiguous block per new name.

Other files touched:

  • src/breaker.py and src/bridge_health.py (connection state);
  • src/services/planning.py (via_events);
  • src/store/follows.py (adopt);
  • src/store/export.py (files, file_path);
  • src/cli/commands/sync.py (--follow, dry run);
  • src/client/endpoints.py;
  • locale files (rule-2 exception).

No new FS_ALLOWLIST or NAMED_EXCEPTIONS entry: every new file access is inside src/store (purge.py, export.py).

Changelog entry

Added

  • Team, player and match follows download their matches.
    • A team follow reads the team's recent and upcoming matches from SofaScore. A player follow reads the player's recent matches. A match follow is that match.
    • The window comes from the follow's seasons: current = the last 365 days, last:N = N years, all = up to five pages back, season ids = those seasons.
    • Every match is downloaded with the follow's data selection.
    • This works in ssc sync (also ssc sync --follow team:42), the scheduler and the web UI's sync (POST /api/v1/jobs with follows). (FX-19)
  • Search teams and players by name: POST /api/v1/tournaments/search with kinds: ["team", "player"] returns typed hits with sport, country and a player's team. (FX-19)
  • Delete one league's data. POST /api/v1/jobs clear with tournament_id (and season_id) deletes that tournament's (or season's) stored matches, schedules and season list. DELETE /api/v1/follows/{id}?delete_data=true removes a follow together with its data. (FX-19)
  • /api/v1/status, /health and /status/check report the connection to SofaScore as never_tried, ok or failed, with the time and reason of the last failure and the last connection check. bridge.last_success_at and bridge.last_failure_at count requests of every transport. (FX-19)
  • GET /api/v1/exports lists and serves the files ssc export wrote into the data folder's exports/. (FX-19)

Changed

  • A follow added in the web UI or through the API is always kept in the follows table and stays editable (enable/disable, seasons, data selection), also without a config file. Before, without a config file, it was written to config/leagues.txt and shown locked. config/leagues.txt is read as before; a follow of it can be moved into the follows table with PATCH /api/v1/follows/{id} {"origin": "api"}. The classic views (/classic, legacy /api/leagues) list only config/leagues.txt. (FX-19)
  • Export files are named after the league or dataset and the date (premier-league_2026-10-06_x7k2m9qa.csv), and downloads use that name. (FX-19)
  • A SofaScore search that finds nothing answers an empty list, also when SofaScore answers 404. (FX-19)
  • /api/v1/status summary.tournaments[].followed counts every follow, including those added in the web UI. (FX-19)

Design mismatches

  • Where new follows go. 02-services.md 2.7 (FollowsService: "With a config file present, new follows get origin api; without one they are written to leagues.txt as today") and the "Follows (P21 part 3)" as-built paragraph: new follows are now always api rows. A legacy row's writable is ["sport", "origin"], and PATCH origin: "api" moves it. The FollowCreate and FollowsService docstrings changed accordingly. config_file of FollowsService no longer decides anything (kept for callers).
  • Section 14 open question of 03-implementation-plan.md ("Can team, player and event follows be synced at all?"): answered and built. 05-web-ui.md G23 is done for every kind. FX-13's "team, player or event follow → 400 unsupported" is gone.
  • Search. 02-services.md 2.7 names search_tournaments. Built: FollowsService.search(query, sport=, kinds=) (search_tournaments remains a wrapper). The route is still /tournaments/search, now with team and player hits under the component name TournamentHit (kept for the frontend's import). A neutral /search route could replace it in P30.
  • Data selection chain. 02-services.md 3.1 / P27's chain (event → tournament → home team → away team) gains a fifth link: the player follow that brought the match (via_events). The match payload does not name the players.
  • Clearing. 01-storage.md 9.3 knows only scope clears. The new Store.purge (Purger.tournament) and its lease, the catalog rebuild and what stays should be added there and in 02-services.md 2.7 (maintenance).
  • Export file names. 02-services.md 6 ("Data jobs (feat(api): v1 export, backup, clear, rebuild and restore-check jobs; exports, backups, logs, diagnostics [P21] #126, feat(export): normalized datasets in JSONL, CSV, Parquet and SQLite [SC-2] #130)": "written to DATA_DIR/exports/<job id>.<ext>") and 03-implementation-plan.md section 17 (/exports does not list ssc export files) are out of date.
  • FailedListing gains the kinds team_events and player_events, with league_id reused for the team's or player's id. A separate field would have changed the job records and the CLI output of every failed listing.
  • The follow sync's window and limits (MAX_LAST_PAGES = 5, 365 days per season step, upcoming matches by /event only, listings not stored) are defined here and belong in 02-services.md 3.2 / 4.1.
  • 01-storage.md 6.3 Scope.followed ("Team, player and event follows do not widen the scope") still holds for the read scope. Team follows' matches are downloaded but are not "followed" in GET /events?followed=true.

Needs live validation

  • /search/all?q=&page=0:
    • the shape of uniqueTournament hits (the trimmed sample has only team and player; built from the tournament search's entity shape, experimental);
    • whether no match is a 404 or an empty list;
    • other result types.
  • /search/unique-tournaments/{q} for a name SofaScore does not know: 404 or empty. Both are handled as [].
  • /team/{id}/events/last/{n} and /next/{n}:
    • the real page size (samples are trimmed to 3) and hasNextPage;
    • next/0 answering 404 for a team without fixtures (the catalog shows 404:6);
    • the sports beyond the nine in the catalog.
  • /player/{id}/events/last/{n}: only football samples, with the event id trimmed out of them, so the id field is assumed. No next endpoint is known for players. Other sports are unknown.
  • MAX_LAST_PAGES = 5 and the cost of a team follow with all (up to 6 list requests plus the matches).
  • The /event-only read of upcoming matches.
  • The connection state on the browser-first path, BROWSER_FIRST (_request_sync → report_ok).

Not done

  • Team and player lists are not stored, because the Store has no team or player schedule slice. They are read again on every sync: at least 2 requests per team and 1 per player.
  • A player's upcoming matches are not read (no catalog endpoint).
  • A team, player or event follow added without a sport keeps sport: null; the search hit's sport should be sent with it (FX-14b).
  • No ssc follows command to move a leagues.txt follow into the follows table (only PATCH).
  • ssc watch still skips player follows (live_follow_skipped).
  • src/services/status.py missing_slice_keys still ignores the follows table's selections (P27's note, unchanged).
  • The connection state is per process: /status of the web server does not see requests of ssc commands or ssc watch.
  • A tournament purge leaves empty bucket directories under v3/events/ and the team and player directories.
  • The frontend (FX-14b) wires all of the above.

Notes for next items

  • FX-14b.
    • Follow editor:
      • search with kinds, prefill sport from the hit;
      • PATCH {origin: "api"} behind an "Move into the app" or "Edit" action for legacy rows;
      • writable tells which fields to enable.
    • Remove dialog: ?delete_data=true and data.clear_job.
    • Maintenance: clear with tournament_id.
    • Health and Overview: connection.state (never_tried → "not tried yet", failed → amber with last_failure_reason), and connection.last_check instead of the per-tab check memory of FX-14a.
    • Exports: source: "file" rows have no job link (job_id null).
    • Texts for the three new job log codes.
  • FX-15 / docs PR. Fold the design mismatches above.
  • P30. /tournaments/search could become /search.

…ollow can be moved there [FX-19]

A follow added through the API or the web UI is always an api row of the
follows table, with or without a config file, so every field can be
changed later (before, without a config file a tournament follow was
written to config/leagues.txt and then shown locked). config/leagues.txt
stays a read-only legacy source; PATCH {"origin": "api"} moves one of its
follows into the follows table (FollowStore.adopt keeps fields and
position; the line leaves the file). /status summary.tournaments[].followed
reads the follows table.
POST /api/v1/tournaments/search takes kinds (tournament, team, player;
default tournament). Tournaments alone keep SofaScore's tournament search;
any other choice asks its general search /search/all?q=&page=0 (the
endpoint of docs/all-sports/endpoints.csv and its research sample), one
request per search. Each hit carries kind, sport, country, a player's team
and whether it is followed. A 404 answer is an empty list, not an
upstream error.
…-19]

A team follow reads SofaScore's /team/{id}/events/next/0 and
/team/{id}/events/last/{n} pages, a player follow /player/{id}/events/last/{n}
(both from docs/all-sports/endpoints.csv), within the follow's window
(seasons current = 365 days, last:N = N years, all = five pages back, season
ids = those seasons); an event follow is that one match. Each match goes
through the fetch pipeline with the follows' data selection (P27's
SelectionPolicy, narrowest follow wins; a player's matches take the player
follow's selection last). A finished match is fetched in full, an unknown
match that has not ended by its /event only.

ssc sync, the scheduler and POST /jobs download them: a sync without a target
takes every enabled follow; follows=[...] accepts team, player and event
follows (was 400 unsupported); ssc sync --follow KIND:ID, and --dry-run counts
them. A list that cannot be read is a failed listing (team_events or
player_events, league_id = the team's or player's id) and the job ends
partial; the request budget, the breaker and cancellation apply.
GET /jobs?target= takes team:<id> and player:<id>.
store.purge.tournament(id, season_id=) deletes a tournament's (or one
season's) events in both layouts, its schedules and, for the whole
tournament, its season list, under the maintenance lease, then rebuilds the
catalog in place (as Store.clear does). Follows, the change log, the job
history, backups and exports stay. MaintenanceService.clear_tournament
calls it.

POST /api/v1/jobs clear takes tournament_id and season_id (scope must stay
all; season_id needs tournament_id); GET /jobs?target=tournament:<id> finds
it. DELETE /api/v1/follows/{id}?delete_data=true removes a tournament follow
once a clear job holds the lease and returns that job as data.clear_job.
/api/v1/status, /health and /status/check carry connection: state
(never_tried, ok, failed), last_success_at, last_failure_at,
last_failure_reason and last_failure_status. The request layer reports every
request's outcome (src/breaker.py report_ok / report_exception into
src/bridge_health.py ConnectionState): an answer (200, 404) is a success;
403, 429, 5xx, timeout, network and parse errors are failures; a request the
breaker held back counts for neither. The bridge state is unchanged (it
counts refusals only and reads ok before any request).
…isted [FX-19]

An export job writes exports/<league or dataset>_<UTC date>_<last 8 of the
job id>.<ext> (for example premier-league_2026-10-06_x7k2m9qa.csv; -raw for
a raw export, -wide for the 2.x wide CSV); the job result's file names it
and the download serves it under that name. Jobs from before keep
<job id>.<ext>.

GET /api/v1/exports also lists the files of exports/ that no job wrote (ssc
export): source file, id file:<name>, job_id null, dataset and format read
from the name; /exports/file:<name>/download serves them. The Store lists
and resolves them (store.export.files, file_path); a name cannot leave the
folder.
…eck [FX-19]

/status and /health bridge.last_success_at and last_failure_at are the last
answered and the last failed request of any transport (curl answers never
reached the browser bridge, so after a successful download the UI still
said "not tried yet"). The bridge's state, series and last error still count
its own refusals, and the circuit breaker and the upstream reasons keep
reading the bridge's own times. connection.last_check {at, ok, reason}
carries the last POST /status/check of this server.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant