Skip to content

Commit 7e64055

Browse files
Implement historical api (#530)
Adds player history support by ingesting historical FIDE rating data. Introduces a new cli module for crawling and ingesting historical CSV snapshots, a HistoryDb persistence layer for player history and federation summary views, and Smithy-defined history API endpoints served by a new HistoryService. The crawler is refactored to use hash-based incremental diffing (with cached hash Refs) so only changed players emit events, and ingestion is skipped entirely when data is already up to date.
2 parents 57f2f5b + 9a2ddb0 commit 7e64055

50 files changed

Lines changed: 2440 additions & 350 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docker.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,8 @@ jobs:
4646
- name: Set up Docker Buildx
4747
uses: docker/setup-buildx-action@v3
4848

49-
- name: Build and push Docker image
49+
- name: Build and push backend Docker image
5050
run: sbt backend/Docker/publish
51+
52+
- name: Build and push CLI Docker image
53+
run: sbt cli/Docker/publish

AGENTS.md

Lines changed: 23 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# CLAUDE.md
22

3-
Guidance for AI agents generating Scala 3 code in this Typelevel-stack project.
3+
Project-specific guidance for the FIDE Lichess Typelevel-stack project.
44

55
## Issue Tracking
66

@@ -15,37 +15,19 @@ Run `bd prime` for workflow context, or install hooks (`bd hooks install`) for a
1515

1616
For full workflow details: `bd prime`
1717

18-
## Global Rules
18+
## Claude session rules
1919

20-
- **Always use `sbt --client`** — never bare `sbt`. Connects to running server instead of launching new JVM.
21-
- **Redirect sbt output**: `sbt --client "module/clean ; test-jvm-2_13" 2>&1 | tee /tmp/sbt-output.txt`
22-
Then inspect with `grep`/`tail`/`head`. Only re-run sbt if code was modified.
23-
- Only run `sbt compile`/`sbt test` after MCP shows no compilation issues.
24-
- After code changes: `sbt lint` (scalafmt + scalafix), then `sbt test`.
25-
- **Do not add yourself as co-author** — no `Co-Authored-By: Claude ...` in commits.
20+
- Always save any plan/optimization session in `<project-root>/.claude/docs/` directory instead of just writing in the console
21+
- Omit `Co-Authored-By` lines from git commits
2622

27-
## Tool references
23+
## Skills
2824

29-
### MCP — Always verify APIs via MCP before using them
25+
**Always** use these skills:
3026

31-
Metals MCP at `.metals/mcp.json`. Query definitions/types/symbols, check compilation errors before running sbt. Fix MCP issues before running sbt to avoid long feedback loops.
32-
33-
**Override Grep with Metals MCP when the question is "what does the compiler resolve this to?"**
34-
35-
Grep is the default and works for most searches. But it fails silently on these Scala-specific scenarios — use Metals instead:
36-
37-
| Scenario | Tool |
38-
|---|---|
39-
| What type is this expression / what does it return? | `mcp__metals__inspect` |
40-
| Which given/implicit is resolved at this call site? | `mcp__metals__inspect` |
41-
| Which overloaded method is called here? | `mcp__metals__inspect` |
42-
| What's the underlying type of an opaque type? | `mcp__metals__inspect` |
43-
| What does a wildcard import bring into scope? | `mcp__metals__inspect` |
44-
| Who calls this method / all implementations of a trait? (semantic, not textual) | `mcp__metals__get-usages` |
45-
46-
Other Metals tools: `glob-search` (find symbols by name), `get-docs` (ScalaDoc), `compile-file` (single-file compile check), `list-modules`, `list-scalafix-rules`.
47-
48-
**Signal to switch:** When you grep and get 10+ candidates with no way to disambiguate — that means you need Metals, not a better regex. Fall back to Grep/Glob for non-Scala files, string literals, config values, SQL, or when Metals is unavailable.
27+
- General Scala 3/FP standards are in the `/scala3-fp` skill.
28+
- cats-effect best practices are in the `cats-effect-io` and `cats-effect-resource`
29+
- typed error with cats-mtl best practices are in the `cats-mtl-typed-errors`
30+
- SBT and Metals workflow is in the `/scala-sbt` skill.
4931

5032
## Modules Structure
5133

@@ -83,100 +65,21 @@ HTTP server and orchestration. Ember server with Smithy4s routes (`PlayerService
8365
### gatling (`modules/gatling`)
8466
Load testing simulations (Gatling). Warmup (2k users), Stress (12k users), and Capacity (incremental ramp) scenarios against `localhost:9669/api`. Independent of other modules.
8567

86-
## Functional Programming Standards
87-
88-
- **No mutable state**: avoid `var`, mutable collections, and `return`.
89-
- **Prefer immutable case classes** over builder patterns.
90-
- If mutability is unavoidable, scope it inside a method — never as class fields.
91-
- Minimize side-effects; push them to the edges (entry points).
92-
- Use ADTs (`enum`) for modeling closed sets of variants.
93-
94-
## Scala 3 Syntax
95-
96-
- **Braceless syntax** — the project uses `rewrite.scala3.removeOptionalBraces = yes`.
97-
- Use Scala 3 `enum`, `given`/`using`, `extension`, `opaque type`, and `derives` where appropriate.
98-
- Use `for`/`yield` (not `flatMap` chains) for sequencing effects — this is the idiomatic pattern in this codebase.
99-
- Prefer `match` expressions over nested `if`/`else`.
100-
101-
## Code Style
102-
103-
- Braceless syntax — no curly braces for `if`, `match`, `for`, class/object bodies.
104-
- `maxColumn = 110`.
105-
- Wildcard imports: `import cats.syntax.all.*`
106-
- `align.preset = none` — do not align on `=` or `=>`.
107-
108-
### Code Style: Flat `for`-comprehensions
109-
110-
Prefer flat `for`/`yield` over nested `match`/`case` inside effectful blocks. Lift `Either`/`Option` into `F` so the `for` stays linear:
111-
112-
```scala
113-
// BAD — nested match in MID-CHAIN breaks the for-comprehension flow
114-
for
115-
result <- service.doSomething(...)
116-
value <- result match // BAD: match in middle, more steps follow
117-
case Right(v) => v.pure[F]
118-
case Left(err) => Sync[F].raiseError(err)
119-
next <- process(value)
120-
response <- Ok(next.asJson)
121-
yield response
122-
123-
// ALSO BAD — .flatMap with case inside for-comprehension
124-
for
125-
body <- req.req.as[Body]
126-
result <- service.getItem(id).flatMap {
127-
case Some(item) => Ok(item.asJson)
128-
case None => NotFound(...)
129-
}
130-
yield result
131-
132-
// GOOD — plain match at TERMINAL position (pure response mapping, no side effects)
133-
for
134-
body <- req.req.as[Body]
135-
result <- service.doSomething(body)
136-
response <- result match
137-
case Right(value) => Ok(value.asJson)
138-
case Left(err) => BadRequest(err.asJson)
139-
yield response
140-
```
68+
### Project-specific opaque types
69+
Defined in `core/domain/Ids.scala` and `core/domain/Types.scala`.
70+
Prefer to use Iron to define new type than opaque
14171

142-
### Code style rules
143-
- **No fully-qualified names in code.** Always use imports.
144-
- **Context bounds: use `{A, B, C}` syntax** (Scala 3.6 aggregate bounds), not colon-separated.
145-
- **Opaque types for domain values.** AI writes 90%+ of code, so write-cost is near zero while compile-time safety is free. Use opaque types with smart constructors for all entity IDs, constrained strings, and bounded numbers. Defined in `core/domain/Ids.scala` and `core/domain/Types.scala`.
146-
- **Type-level constraints flow E2E.** Encode invariants in types (opaque types, `NonEmptyList`, refined types) and propagate them through **all** layer signatures: route → service → repository. Never downgrade a constraint to a weaker type and re-validate internally — that hides the requirement from callers and defeats compile-time safety. Unwrap/weaken only at the true system boundary: SQL interpolation, Java SDK calls, job parameter serialization.
147-
- **`.toString` over `.value.toString`.** Opaque types erase at runtime, so `s"...$opaqueId"` and `opaqueId.toString` just work — no need to unwrap first.
148-
- **`NonEmptyList` over `List` + `.get`/`.head`.** When a method logically requires non-empty input (batch embeddings, `IN` clauses, etc.), use `NonEmptyList[T]` in the **signature** — including repository methods — instead of `List[T]` with a runtime `.toNel.get` or `.head`. Callers use `NonEmptyList.fromList` to handle the empty case at the call site.
149-
- **No premature helpers.** If the logic can be composed from <5 Scala/cats operators, always inline at call site — never extract a helper. If >=5 operators, **ask the user** before extracting (in plan mode or popup dialog). When consensus is reached on a new helper, add/link it in this document so future sessions know to use it. **Always use helpers already listed here** (e.g., `AsyncOps`) — don't expand them inline. Before writing any new helper, search the codebase for existing ones that do the same thing.
150-
- **Generic over specific (stdlib/cats only).** Prefer composing well-tested Scala/cats operators generically over type-specific. "Generic" means leveraging stdlib type classes, not extracting custom helper functions — those still follow the <5 operator rule.
151-
- **Proactive naming review.** When modifying code, flag misleading, stale, or inconsistent names to the user. **Scope follows the compiler iteratively** — same as smell detection: start with changed files, then follow compilation errors outward. For **internal names** (classes, properties, methods) — recommend renaming directly. For **external names** (request/response DTOs, DB-serialized JSONB fields) — suggest the better name but note migration implications.
152-
- **Proactive code smell detection.** Scope follows the compiler iteratively: (1) find smell in current file, (2) fix it, (3) compile → if it fails because other files import the changed symbol, fix those too, (4) repeat until compilation passes. If **reading unrelated code** (not in the compilation chain) and spotting a violation — **add it to the smell list** (see Code Smell Tracking below), do not fix. This applies to all rules: type safety, error handling, control flow, naming, logging, etc.
153-
154-
### Error Handling
155-
156-
- **No error swallowing.** Unless the business scenario *explicitly* requires a default value, using `.getOrElse`, blanket `try`/`catch`, `.recover`, `.handleErrorWith`, or `.orElse` to silently discard failures is forbidden. Every error must be surfaced — either raised via `Raise` or propagated in the type.
157-
- **Prefer cats-mtl typed errors over `Either`.** Use `Raise[F, E]` / `Handle[F, E]` to short-circuit errors in `F` directly, keeping for-comprehensions flat. Avoid `IO[Either[E, A]]` return types — they force callers to unwrap and nest. Reserve `Either` for pure validation logic or values that cross serialization boundaries.
158-
159-
```scala
160-
// BAD — Either in return type, callers must unwrap
161-
def fetchUser(id: UserId): IO[Either[AppError, User]]
162-
163-
// GOOD — error raised in F, callers just flatMap
164-
def fetchUser[F[_]: {Async, Raise[AppError]}](id: UserId): F[User]
165-
```
166-
167-
- **Trusted vs untrusted data paths.** Internal data (config, DB-persisted values, intra-service calls) is trusted — failures are bugs, raise directly (`MonadThrow`). External data (user input, third-party APIs) is untrusted — capture failures via `Raise` so callers `Handle` them explicitly at the boundary.
168-
- **Trust the compiler.** What the signature declares, trust unconditionally. Do not defensively re-validate what the type system already guarantees (e.g., don't `require(nel.nonEmpty)` on a `NonEmptyList`, don't null-check an opaque type). Every act of distrust costs tokens exponentially — the agent opens the implementation, reads dependents, context bloats, and subsequent steps degrade.
169-
- **Signature completeness.** Every function must honestly declare its effects (`IO`/`F[_]`), failure modes (via `Raise[F, E]` constraint or `Option`), and use domain types for parameters. A signature like `def fetchUser(id: String): User` is dishonest — it hides effects and error paths. Prefer `def fetchUser[F[_]: {Async, Raise[AppError]}](id: UserId): F[User]`.
170-
171-
### NoOp Service Implementations
72+
### Proactive code smell detection
17273

173-
When providing stub/noop implementations of service traits:
174-
- **Data-related operations MUST fail** — raise the error or return a failed effect. Silent success would mask missing wiring.
175-
- **Data-irrelevant operations CAN succeed** — metrics, logging, telemetry noops return `().pure[F]` since skipping them is safe.
74+
Scope follows the compiler iteratively:
75+
(1) find smell in current file
76+
(2) fix it
77+
(3) compile → if it fails because other files import the changed symbol, fix those too
78+
(4) repeat until compilation passes. If **reading unrelated code** (not in the compilation chain) and spotting a violation — **add it to the smell list** (see Code Smell Tracking below), do not fix. This applies to all rules: type safety, error handling, control flow, naming, logging, etc.
17679

17780
### Code Smell Tracking
17881

179-
When spotting code smells in **unrelated code** (not in the current compilation chain), add them to the persistent smell list file at `<project-root>/memory/code_smells.md` instead of just warning in the response.
82+
When spotting code smells in **unrelated code** (not in the current compilation chain), add them to the persistent smell list file at `<project-root>/.claude/memory/code_smells.md` instead of just warning in the response.
18083

18184
**Rules:**
18285
- **Max 10 entries.** If adding an 11th, delete the oldest entry (FIFO eviction).
@@ -190,55 +93,15 @@ When spotting code smells in **unrelated code** (not in the current compilation
19093
For non-trivial tasks, generate a plan before writing code:
19194
1. Identify affected modules and files.
19295
2. List the changes needed in each file.
193-
3. Note any schema/migration impacts (Smithy, ES mappings).
96+
3. Note any schema/migration impacts.
19497
4. Confirm the plan before implementing.
19598

19699
After implementing a group of related changes, review for:
197100
- Correctness (does it compile and pass tests?)
198101
- Consistency (does it follow existing patterns in the codebase?)
199102
- Simplicity (is there a simpler way to achieve the same result?)
200103

201-
## Things to Avoid
202-
203-
- Do NOT use `Future`, `Await`, `Thread.sleep`, or blocking calls on the IO thread pool.
204-
- Do NOT use Ox, ZIO, or direct-style concurrency libraries — this project is cats-effect.
205-
- Do NOT manually define ES field mappings — they are generated from Smithy schemas.
206-
- Do NOT reformat snapshot JSON files (`elastic/src/test/resources/snapshot/mappings/`).
207-
- Do NOT wrap `Option[Boolean]` in `Some()` for Smithy trait properties.
208-
209-
210-
## Rule Hierarchy — Four Layers of Constraint
211-
212-
Not all rules are equal. When adding or interpreting rules in this file, classify them by enforcement layer:
213-
214-
| Layer | Enforced by | Examples | Agent action |
215-
|---|---|---|---|
216-
| **1. Compiler** | Type system, exhaustiveness, opaque types | `Raise[F, E]` in signature, sealed trait match | No rule needed — compiler rejects violations |
217-
| **2. Actionable rules** | This file — clear if/then criteria | "No error swallowing", "NonEmptyList over List + .head" | Follow unconditionally; violations are code smells |
218-
| **3. Process constraints** | Filesystem as cross-session memory | Code smell tracking in `code_smells.md` | Agent discovers → records → human prioritizes |
219-
| **4. Advisory** | Agent suggests, human decides | "Consider adding runtime assertion here", deploy impact | Flag to user, do not act unilaterally |
220-
221-
Push constraints **up** whenever possible: prefer a compiler-enforced type over an actionable rule, prefer an actionable rule over an advisory suggestion.
222-
223-
## Rule Quality Standard
224-
225-
Rules in this file must be precise enough that the agent never needs to read extra context to guess intent. Every ambiguity = extra file reads = token bloat = degraded reasoning.
226-
227-
```
228-
// BAD — vague, requires judgment calls
229-
"Always use tagless final style"
230-
"Be pragmatic about error handling"
231-
232-
// GOOD — unambiguous if/then, no interpretation needed
233-
"ParserService should be a typeclass constraint in the class constructor:
234-
class FileProcessor[F[_]: {Async, ParserService}]
235-
NOT as a parameter:
236-
def process[F[_]](parser: ParserService[F])"
237-
```
238-
239-
When writing new rules: lead with the concrete pattern (what to do), then the anti-pattern (what not to do), then rationale (why). If a rule can't be expressed as a clear if/then, it belongs in Layer 4 (advisory), not Layer 2.
240-
241-
### Fixing a bug
104+
### Fixing a Bug
242105

243106
1. Write a failing test reproducing the bug
244-
3. Verify the test fails, apply fix, clean again, verify all tests pass
107+
2. Verify the test fails, apply fix, clean again, verify all tests pass

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,22 @@ Then run:
116116
```bash
117117
docker compose up -d
118118
```
119+
120+
## Run CLI without building
121+
122+
You can use the pre-built CLI Docker image from [GitHub Container Registry](https://github.com/lenguyenthanh/fide/pkgs/container/fide-cli) to ingest historical CSV files into the database.
123+
124+
Assuming CSV files are in `./csv-data` and the docker-compose Postgres is running:
125+
126+
```bash
127+
docker run --rm \
128+
--network fide_fide \
129+
-v ./csv-data:/data \
130+
-e POSTGRES_HOST=fide_postgres \
131+
-e POSTGRES_PORT=5432 \
132+
-e POSTGRES_USER=admin \
133+
-e POSTGRES_PASSWORD=dummy \
134+
-e POSTGRES_DATABASE=fide \
135+
ghcr.io/lenguyenthanh/fide-cli:latest \
136+
ingest /data --start 2024-01 --end 2024-12
137+
```

build.sbt

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ lazy val api = (project in file("modules/api"))
4949
smithy4sWildcardArgument := "?",
5050
libraryDependencies ++= Seq(
5151
smithy4sCore
52-
)
52+
),
53+
Compile / scalacOptions += "-Wconf:src=target/scala[^/]*/src_managed/.*:silent"
5354
)
5455
.dependsOn(types)
5556

@@ -109,6 +110,30 @@ lazy val backend = (project in file("modules/backend"))
109110
.enablePlugins(JavaAppPackaging, DockerPlugin)
110111
.dependsOn(api, domain, full(db), crawler)
111112

113+
lazy val cli = (project in file("modules/cli"))
114+
.settings(
115+
commonSettings,
116+
name := "cli",
117+
libraryDependencies ++= Seq(
118+
fs2IO,
119+
fs2DataCsv,
120+
fs2DataCsvGen,
121+
declineCore,
122+
declineCatsEffect,
123+
cirisCore,
124+
cirisHtt4s,
125+
ironCiris,
126+
logback % Runtime
127+
),
128+
Compile / run / fork := true,
129+
Compile / run / connectInput := true,
130+
Docker / packageName := "lenguyenthanh/fide-cli",
131+
Docker / maintainer := "Thanh Le",
132+
Docker / dockerRepository := Some("ghcr.io")
133+
)
134+
.enablePlugins(JavaAppPackaging, DockerPlugin)
135+
.dependsOn(domain, full(db))
136+
112137
lazy val gatling = (project in file("modules/gatling"))
113138
.settings(name := "gatling")
114139
.enablePlugins(GatlingPlugin)
@@ -124,9 +149,9 @@ lazy val gatling = (project in file("modules/gatling"))
124149
lazy val root = project
125150
.in(file("."))
126151
.settings(publish := {}, publish / skip := true)
127-
.aggregate(types, api, domain, db, crawler, backend, gatling)
152+
.aggregate(types, api, domain, db, crawler, backend, cli, gatling)
128153

129154
def full(p: Project) = p % "test->test;compile->compile"
130155

131-
addCommandAlias("lint", "scalafixAll; scalafmtAll; scalafmtSbt")
156+
addCommandAlias("prepare", "scalafixAll; scalafmtAll; scalafmtSbt")
132157
addCommandAlias("lintCheck", "; scalafixAll --check ; scalafmtCheckAll; scalafmtSbtCheck")

modules/api/src/main/scala/providers.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package fide.spec
22

3-
import fide.types.{ NonEmptySet, PageNumber, PageSize }
3+
import fide.types.{ NonEmptySet, PageNumber, PageSize, YearMonth }
44
import smithy4s.*
55

66
object providers:
@@ -25,6 +25,9 @@ object providers:
2525
given RefinementProvider[FederationIdFormat, String, fide.types.FederationId] =
2626
Refinement.drivenBy(fide.types.FederationId.either, _.value)
2727

28+
given RefinementProvider[YearMonthFormat, String, YearMonth] =
29+
Refinement.drivenBy(YearMonth.fromString, _.format)
30+
2831
given [A]: RefinementProvider[NonEmptySetFormat, Set[A], NonEmptySet[A]] =
2932
Refinement.drivenBy[NonEmptySetFormat](
3033
NonEmptySet.either,

modules/api/src/main/smithy/_global.smithy

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ structure PageSizeFormat { }
8484
)
8585
structure BirthYearFormat { }
8686

87+
@trait(selector: "string")
88+
@refinement(
89+
targetType: "fide.types.YearMonth"
90+
providerImport: "fide.spec.providers.given"
91+
)
92+
structure YearMonthFormat {}
93+
8794
@trait(selector: "list")
8895
@refinement(
8996
targetType: "fide.types.NonEmptySet",

0 commit comments

Comments
 (0)