Backend
Cloudflare Worker · TypeScript- lines
- 64.9% 1202/1853
- branches
- 65% 640/984
- functions
- 62.6% 261/417
Routes are excluded from the report: they are covered end to end by the integration tier, which drives them through real HTTP.
What the game is, the loop a player is in, and the vocabulary the rest of this document is written in.
Every day, millions of people read Wikipedia. A footballer scores, a country makes the news, a film releases, and the pageviews spike.
FantasyWiki turns that into a fantasy league. A player joins a league, gets a budget, buys article contracts, arranges them into a formation, and scores as the world reads. The season runs for weeks; the squad never stops needing management.
This page is the orientation layer. It says what the pieces are and where each one is specified, it does not restate any of the rules, because every rule in this system is written down exactly once, under domain/.
Rendering diagram…
Four decisions, repeated: what to own, where to place it, when to let it go, and which league to spend the effort in.
Each of these is a body of rules with one canonical document. If two pages ever disagree about one of them, the one linked here is the one that is right.
| System | The question it answers | Canonical |
|---|---|---|
| Scoring | How a day's pageviews become points, and why doubling readers is a step rather than a jackpot | Scoring & Economy System |
| Chemistry | Why two articles score extra when they sit side by side, and what "side by side" means | Chemistry Links |
| The economy | What a contract costs, what it pays back, and why the money supply is closed | ADR 0003 · ADR 0005 |
| Leagues | Who can join, when a season starts and ends, and what happens to a league nobody plays any more | League Lifecycle · League Season |
Five deployable pieces and one shared vocabulary. The frontend and the Worker share their types through two framework-free packages; the nightly collector shares nothing with either but an HTTP contract and a secret.
Rendering diagram…
The layers inside each of those, and the seams between them, are in the architecture overview. The one worth knowing before reading any further is why the collector exists at all.
The obvious design scores a team when someone opens the app. It does not work, for a reason worth stating plainly: a day's pageviews are only knowable after the day is over. Wikimedia publishes them in arrears, the same figure for everybody, and no amount of asking earlier produces a number.
So scoring is a batch over yesterday, run once, for every team in every league.
Rendering diagram…
Two properties keep this honest, and both are architectural rather than incidental:
The collector computes nothing. It posts raw facts, views per article, the resolved level of each chemistry link, and the Worker turns them into points using the single implementation in model/scoring.ts. One scoring formula, in one language, so the JVM and the TypeScript runtimes cannot drift apart.
The collector knows nothing about the game. No formation, no position, no language calibration. The backend resolves the formation into a flat list of article pairs and hands them over. Adding a formation touches one enum.
The full pipeline, including the failure modes and the cost analysis, is in Nightly Scoring Pipeline.
The domain has a vocabulary and it is enforced: Top Read Snapshot, Article Availability, Chemistry Link, Free Agent, Owner Team. These are not stylistic preferences, they are the terms the code, the commit messages and these docs all use, and each one has an "avoid" list of the synonyms that would otherwise creep in.
That glossary is CONTEXT.md, and it is canonical. A concept that does not have a name there does not yet have a name. It is rendered, term by term, on the vocabulary.
The documentation under docs/ is grouped by what would have to change for a document to become wrong:
domain/: a game rule changedarchitecture/: a refactor changed itdevelopment/ and deployment/: a tool or an environment changedadr/: nothing changes it; a decision record is immutable, and when an ADR disagrees with any other document, the ADR winsThose documents live in the repository, beside the code. Pages like this one, the architecture overview and the data flow do not: they carry diagrams and orientation, and deliberately carry no rules, so there is nothing on them that can contradict the repository. Which is which, and why, is in About this site.
The domain has one set of words and they are not stylistic preferences. A term below is what the code names its types after, what a commit message says, and what these pages call the thing, and each one carries the synonyms it exists to displace, because the cost of a domain language is paid entirely in the near-misses that creep in beside it.
A bold word inside a definition is another term on this page, so a concept can be followed rather than looked up. A concept with no entry here does not yet have a name.
A term marked core is one nothing else in the vocabulary can be read without: the entities the game is played with, the economy they move in, and the scoring they feed. Those are the terms the single-document exam report carries, so that a document read straight through introduces the words it then uses rather than the whole ingestion pipeline.
The 30 terms the rest of this document is written in. The whole vocabulary, 52 terms, is on the vocabulary page.
A daily ranking result bound to a specific Project Domain and retrieval date.
Neverlive rankingreal-time top list
A per-language constant that multiplies raw pageviews to lift each language onto one common reference scale (the reference language's factor is 1.0; lower-volume languages get a factor > 1). A single factor per language drives both tier granularity and synergy balance, so the tier model and synergy table are tuned once and reused for every language.
Neverper-language tier tableper-language synergy tablecross-league multiplier
Raw pageviews multiplied by the Language Scale Factor. This is the input to the universal tiered base-points model, never raw pageviews directly.
Neveradjusted viewsweighted views
The view-driven score of one article for a day, computed from its Normalized Views by the universal geometric model: max(0, log₂(views / 2000)) up to a 150k kink, then linear above at +1 point per 50,000 views. Continuous and decimal-valued (the "+1 per doubling" rule is just the headline at the rungs); crosses zero at 2,000 views. Concave (diminishing) in the mid-field, convex at the top so the volatile daily elite is rewarded. Excludes synergy and events.
Neverraw pointspageview points
A person with an account, identified by a sign-in and a username. A player holds one Team in each League they belong to, and nothing they own in one league counts in another.
Neverusermanagercoach
A player's single entry in one League: a name, a Credits balance, the Contracts it holds and the Formation it fields. Ownership, scoring and standing are all team-level and never player-level, so a player with teams in three leagues is playing three separate games.
Neversquadclubrosterprofile
The competition a set of Teams play in. It fixes one Wikipedia language edition, one Season, one League Visibility, one Invite Policy and the Language Scale Factor frozen at the moment it was founded, and every price and every point inside it is computed against those.
Neverroomlobbygrouptournament
The one public League every player is enrolled in by naming their first Team. Its id is the fixed string global and nobody may leave it, so it is where a player plays before they have joined or founded anything else.
Neverdefault leaguemain leaguehome league
The player who created a League. The admin may close it before its Season ends, and under the admin Invite Policy is the only one who may hand out its Invitation Code.
Neverownermoderatorhost
The stretch a League runs for, chosen at creation from two weeks, one, two, three or six months and starting the moment the league is created. The player picks a length and never an end date. The two-week floor is the shortest season in which a LONG Contract Tier can still reach Expiry Settlement.
Nevergameweekmatchdayroundcampaign
The ownership status of an article at the time detail is shown.
One ofFree AgentOwned by ViewerOwned by Other Team
Nevergeneric unavailable flag
A Team's holding of one article for a committed term: signed at a Purchase Price, running from its purchase date to its expire date, and leaving by exactly one of Early Sell or Expiry Settlement. An article is never held loosely, only under a contract, which is why the contract and not the article is what scores, what is priced and what is placed on a Position.
Nevertransferplayer cardownership record
A lineup that assigns contracts to the positions required by a Formation Schema.
Neverlineuproster
A named slot in a formation (e.g., LW, CM, GK) where an article contract can be placed.
Neverslotrole
A named layout that defines which Positions are required and which Chemistry Links exist between them.
Neverformation typelayout only
A schema-defined connection between two Positions that carries a Chemistry Level for the articles placed there.
Neveradjacency lineedge
A four-step rating (Excellent/Good/Weak/Empty) assigned to a Chemistry Link that drives UI color and an additive score contribution (flat points), not a multiplier. Chemistry is only evaluated between schema-adjacent Positions, not between every pair of owned articles.
Nevercolor tiersynergy multiplierall-pairs synergy
One Team's score for one Snapshot Date (UTC): the Base Points of the articles placed on its Positions, plus the flat points its Chemistry Links carry, stored once per team per day. It is the only thing Standings are built from.
Nevermatchfixturegameweekresult
The nightly batch that scores every Team in every League on the previous day's pageviews. It reads each team's placed articles from the backend, fetches per-article views and the link graph from Wikimedia, and posts back raw facts rather than points, because the scoring model has one implementation and the collector is not it. See docs/architecture/scoring-pipeline.md.
Neverscoring jobcronscorer
A League's table: cumulative Daily Performance points, descending, with each team's movement against the order as it stood before its own last scored day. Nothing else is weighted in, not credits, not team value, not days played, and ties are broken by nothing. The API and its DTOs call the same table leaderboard. See docs/domain/standings-and-podium.md.
Neverrankingscoreboardleague position
The screen a Team signs Contracts from: search across articles, the Article Availability of each, and the Article Genie for the article a player cannot name. Every contract begins here.
Nevershopstoretransfer marketauction
The committed duration picked when a Contract is signed: SHORT is 3 days, MEDIUM 7, LONG 14 (ADR 0005). The tier is what Contract Price charges for and what Early Sell prorates against, and a renewal rolls the same length forward rather than choosing a new one.
One ofSHORTMEDIUMLONG
Nevercontract length in weeksplansubscription
What a Team pays for Contracts with, and the only currency in the game. Never a stored balance: credits are derived on every read from the team's own contracts, starting budget minus every Purchase Price paid, plus every payout already settled back (ADR 0007). Affordability is enforced inside the statement that writes the contract, not above it.
Nevercoinsmoneywalletbudget
The credits required to hold an article, D × BasePoints(Normalized_30-day-average_Views)^k × contract_days (ADR 0005, derived from the scoring curve's own BasePoints shape, not raw views; days-based; supersedes both the original linear /1000 × weeks form and an intermediate convex-in-raw-views form). Priced on the smoothed 30-day average, never daily views, this is the deliberate decoupling that makes daily spikes cheap-but-fleeting and sustained popularity expensive. The k exponent applies convexity to the already-log-compressed points value (not to raw, power-law-skewed views), so giants/top-tier articles cost progressively more per marginal point than mid-tier ones without the price curve diverging from the value it buys.
Neverdaily-view pricespot pricelinear-in-views priceconvex-in-views price
Purchase Price is the Contract Price locked at signing. Current Price is the same formula re-evaluated with live 30-day-average views, at the contract's original tier duration, a "replacement cost" number (ADR 0003). It's the shared basis for both exit paths: prorated for an Early Sell, or a full Expiry Settlement against Purchase Price at natural term completion.
Neverfixed price onlysingle priceresale price (ambiguous about which exit path)
Voluntarily exiting a contract before its committed term ends: Current Price × (remaining days / tier days) credited, pays only for the unused portion of the term, at today's rate (ADR 0003). Proration is the sole anti-exploit guard (there is no minimum hold): holding 3 of 14 days recovers only 11/14, so a partial hold can never return the full price plus free days of points.
Neverresalecash out (ambiguous with Expiry Settlement)
The "sold to system" outcome when a contract completes its full committed term without renewal: the team is credited the full Current Price, i.e. Purchase Price + (Current Price − Purchase Price) (ADR 0003). The buy already debited Purchase Price, so expiry returns the whole stake plus the mark-to-market P&L, net profit if views rose over the whole hold, net loss if they fell (crediting only the delta, as an earlier draft did, would wrongly forfeit the stake). Can only trigger by holding the entire term, so it can't be reached via early exit. This is the mechanism that makes buying a viral/trending-spike article at its peak genuinely risky: a spike that fully reverts before the term ends turns into a real loss at settlement, not just "no profit." Runs via a daily Cloudflare Cron sweep on the backend (single money-writer, ADR 0004), idempotent on a contract status guard.
Neverexpiry (bare)auto-sellcontract end (without specifying settlement)
The owner's choice, in the final 24h of a Contract's term, to renew rather than let it reach Expiry Settlement (ADR 0003). The default is to let it expire and the choice locks for expiry once the window closes. Renewing rolls the term forward at the Current Price plus the Renewal Premium.
Neverright of first refusalauto-renewextension
The market-page assistant that finds articles a player cannot name exactly (ADR 0006). It serves two intents through one input: chemistry scouting ("find me a relation between OpenAI and Portugal") and tip-of-the-tongue recall ("the female mathematician who worked at NASA"). It never invents a title, it seeds a bounded list of real articles from search and uses an LLM only to narrow it through questions. When the daily model quota is exhausted the Genie is asleep and the market falls back to the ordinary search bar.
NeverAkinator assistantAI searchchatbot
Whether a league is public (any player may join) or private (joining requires its Invitation Code, or being the League Admin). Visibility governs joining only, a private league's page and standings stay readable by anyone with its id. The Global League is public. See docs/domain/league-visibility.md.
Neverleague privacyclosed leaguelocked league
Rendered from CONTEXT.md, which is where it is edited and where the entity relationships and the resolved ambiguities are also kept.
How the model was discovered before it was written down, which DDD archetype each concept became and the perimeter the model deliberately sets, and one aggregate invariant followed all the way to the line that enforces it.
Requirements here are traceable rather than declared. Every functional obligation points at the document that specifies it and, where it is already built, at the module that satisfies it. A requirement with no document is a requirement nobody has agreed on yet, and this page says so rather than implying otherwise.
The original brief is the Game Design Document, FantaWiki Requirements v5.5. It is kept as written and is partly superseded: its scoring, economy and tournament sections were reconciled against the locked design, and where it disagrees with an ADR, the ADR wins.
The brief was not written in one sitting and then built. The concept was put in front of people first: described, argued with, and revised on what came back, before any of it became a requirement. That round is why several of the obligations below are narrower than the original idea.
The starting point was Polymarket, a prediction market where people buy and sell shares in the outcome of a real-world event, so the share price reads as the crowd's estimate of how likely it is.
The observation this project rests on followed from it: Wikipedia pageview volume tracks the same trends a prediction market prices, and it is free to read.
Two ideas were dropped on the way to the current one:
| Idea | Why it was dropped |
|---|---|
| A prediction market of open-ended bets, on anything | No way to settle them. An arbitrary bet needs an oracle the project could not build or afford |
| Trends sourced from Google search analytics | The analytics API costs hundreds of dollars a month, which a project running on free tiers cannot justify |
Dropping settlement is what turned the concept from a market into a game. With no bet to adjudicate, scoring had to come from a formula over public numbers; once scoring is a formula, the format that fits it is fantasy football. The derivation in full: prediction market, minus settlement, plus a squad.
Who: the university friends who would later play it, and other people from the university outside that group.
How: informally, before anything was built. A conversation, a walkthrough of the product, then a short spoken set of questions on what they made of the idea and whether they would play it.
What came back:
| Response | Effect on the requirements below |
|---|---|
| Broadly positive. The appeal was using Wikipedia data as a game about current news and trends | The premise was kept |
| A preliminary auction for articles was rejected as tedious. A fantasy-football auction works because the players can be listed; Wikipedia articles cannot be enumerated that way | The market is a fixed price against a live valuation, not an auction |
| Google analytics would be the better data source | Priced out, as above |
The leaderboard. Neither group questioned how teams would be ranked against each other. Total points across the season went unchallenged, and nobody raised head-to-head fixtures, a tournament, or any alternative.
That is the one worth recording, because a requirement nobody questioned and a requirement that survived scrutiny look identical once written down. The playtest separated them: players reported that nothing felt competitive and asked, unprompted, for the head-to-head format nobody had thought to raise beforehand.
The auction ran the other way. Rejected at the pitch, then requested by the same people after a month of play.
The second round of evidence came much later and from real play rather than opinion. A month on production, in the playtest.
Requirements are easier to argue with as a domain model than as a list. Four stories, told the way they would be told on a wall: who asks for something, what they ask for, which thing is allowed to say yes, what became true as a result, the values the decision is made of, and the policy that answers without being asked.
Each role carries its own outline as well as its own colour: a circle acts, a rounded box is what was asked for, a bracketed box is what may say yes, a flag is what became true, a hexagon is a value it is made of, and a slanted box is a standing rule that answers before anyone asks. The shape is the legend; the colour only agrees with it.
Rendering diagram…
As a league admin, I want to found a league on the Wikipedia edition my friends read, so that we compete over articles we recognise.
Rendering diagram…
A league freezes its Language Scale Factor at founding rather than reading it live, because a later recalibration would silently re-rate every contract already priced. → ADR 0002
As a player, I want to buy the articles I think the world is about to read, so that my squad scores before anyone else notices.
Rendering diagram…
An article is a Free Agent or somebody's, never both, and the price comes from a smoothed thirty-day average rather than yesterday's spike. What is paid at signing is fixed there; what the contract is worth on the way out is worked out again on the day. → ADR 0005 · ADR 0003
As a player, I want to place my contracts where they reinforce each other, so that arranging the squad is a decision and not decoration.
Rendering diagram…
Two adjacent articles score extra when Wikipedia itself links them, so where a contract sits is worth as much as which contract it is. → Chemistry Links
As a player, I want yesterday settled before I wake up, so that the standings are a fact rather than a calculation I have to trigger.
Rendering diagram…
The actor here is the clock, and that is the whole reason the collector exists: a day's pageviews are only knowable once the day is over. → What FantasyWiki is
The wall carries the four stories the rest of the game hangs off. Every functional requirement below has a story of its own, and the ones the wall does not draw are told here in words: who wants it, what they want, and what it is for. The "so that" is the part to argue with, because it is what a requirement is measured against when two of them pull apart.
| # | Story |
|---|---|
| F1 | As a player, I want to sign in with the Google account I already have and stay signed in, so that I never create or remember another password for a game. |
| F1b | As someone running FantasyWiki on their own machine without a Google project, I want to register and sign in with a username and a password, so that the local build is playable without registering an OAuth client. |
| F2 | As a League Admin, I want to found a league on the edition my friends read. On the wall: Founding a league. |
| F3 | As a League Admin, I want my league closed to anyone without its Invitation Code, and to decide whether members may pass the code on, so that the league is the group of friends I meant it for. |
| F4 | As a League Admin, I want to choose how long the Season runs, from two weeks to six months, so that the league lasts as long as my friends will stay interested and ends with a result. |
| F5 | As a player, I want to buy an article at a price that reflects its recent month, not yesterday's spike. On the wall: Buying a contract. |
| F6 | As a player, I want a contract I held to its term settled at its live value, gain or loss, so that picking an article before it trends is rewarded and buying one at its peak is a real risk. |
| F7 | As a player, I want yesterday scored before I wake up. On the wall: Scoring the night. |
| F8 | As a player, I want adjacent articles that link to each other to score more. On the wall: Fielding a formation. |
| F9 | As a player, I want to see at a glance whether an article is free, mine, or another team's. On the wall: Buying a contract. |
| F10 | As a player, I want to rearrange my formation without ever losing a contract. On the wall: Fielding a formation. |
| F11 | As a player whose league has ended, I want to open its standings and my team as they finished, so that the season we played is something we can look back at, not something that vanished. |
| F12 | As a player who hit a problem, I want to report it from inside the app and attach a screenshot, so that telling the authors costs me less than giving up. |
| F13 | As a player who half-remembers an article, or wants one that links two others, I want to describe it and answer questions until it is found, so that I can buy an article I cannot name. |
The same obligations as a table, each pointing at what specifies it and, where it is already built, at what satisfies it.
| # | The system must… | Specified in | Built in |
|---|---|---|---|
| F1 | Authenticate a person through Google and hold the session in an HTTP-only cookie | Data flow | backend/src/routes/auth.ts |
| F1b | Also admit a username and password, in the build that has a credential store to check them against | Auth Modes | routes/passwordAuth.ts · indexPassword.ts |
| F2 | Let a player found a league on any Wikipedia edition that passes the calibration floor | Wikipedia Language Editions | services/wikipediaEditions.ts |
| F3 | Keep a league private unless its founder says otherwise, and admit by code | League Visibility · ADR 0008 | services/invitationCode.ts |
| F4 | Bound a season between two weeks and six months | League Season | services/league.ts |
| F5 | Price a contract from a smoothed 30-day average rather than a spike | ADR 0005 | model/pricing.ts |
| F6 | Keep the money supply closed: no stipend, no fee, gains and losses settled at expiry | ADR 0003 · ADR 0007 | workflows/contractSettlement.ts |
| F7 | Score each team on the previous day's pageviews, once, for every league | Scoring & Economy System | scoring-collector/ + services/scoring.ts |
| F8 | Award chemistry for adjacent articles that link to each other on Wikipedia | Chemistry Links | services/performance.ts |
| F9 | Show a player which articles are free, theirs, or another team's | Article Availability | model/contract.ts |
| F10 | Never lose a contract when a formation changes | Lineup Rules | services/lineup.ts |
| F11 | Keep a league readable after it ends, and never delete what someone can still read | League Lifecycle | services/league.ts |
| F12 | Let a player report a problem without leaving the app | Problem Reports | services/problemReport.ts |
| F13 | Answer questions about an article without naming it, as a guessing game | ADR 0006 · Article Genie | services/articleGenie.ts |
A requirement is done when all of the following hold, and not before:
CONTEXT.md.backend/openapi.yaml, and the suite that gates the spec against the mounted routes passes.ci-cd / success is green on the pull request: format, lint, typecheck, audit and every suite.master, which means deployed to production, and released if the change was a feat or a fix (Release Process).The table is condition 2, requirement by requirement: what has to be observably true, and the test that shows it. Where the test covers less than the requirement says, the gap is named rather than rounded up.
| # | Done when | Shown by | Gap |
|---|---|---|---|
| F1 | A sign-in sets a session_token cookie whose JWT the /api/* guard accepts, and a request without it is refused with 401 | routes/index.spec.ts · integration/devAuth.integration.test.ts | The Google callback's own cookie is asserted through the shared session code, not by a test of the callback |
| F1b | Registering and signing in with a password mints the same session, a wrong password and an unknown user answer alike, and no password is stored in clear | integration/passwordAuth.password.test.ts · auth/passwordHash.spec.ts | |
| F2 | Every live edition is offered; one below the calibration floor is refused and writes nothing; one never played is calibrated before its league exists | integration/languageScaleCalibration.integration.test.ts · wikipediaEditions.spec.ts | |
| F3 | A private league turns away anyone without its code, admits the right code however it was typed, and one league's code opens no other | integration/leagueVisibility.integration.test.ts · integration/leagueCreation.integration.test.ts | Visibility is a required field, so "private unless the founder says otherwise" is the form's default, which no test asserts |
| F4 | Only the offered season lengths are accepted, and a league runs for exactly the length chosen | integration/leagueCreation.integration.test.ts | The two ends, two weeks and six months, are not asserted on their own |
| F5 | A price is computed from the 30-day average alone, and a purchase with no 30-day average is refused rather than priced from one day | contract.spec.ts · integration/contract.integration.test.ts | The averaging arithmetic itself has no dedicated test |
| F6 | An expired contract settles at the live price, at a profit or a loss, with nothing deducted, and a team with no contracts holds exactly the starting budget | integration/contractSettlement.integration.test.ts · repositories/conformance/derivedCredits.integration.test.ts | |
| F7 | Ingesting the same team and day twice leaves one performance, recomputed, not two | integration/scoring.integration.test.ts · repositories/conformance/performanceRepository.integration.test.ts · CollectorTest.kt | That the nightly run picks the previous UTC day is structural, not asserted |
| F8 | Two adjacent articles that link both ways are Excellent, one way Good, not at all Weak, and each level adds its points | ChemistryTest.kt · integration/scoring.integration.test.ts · scoring.spec.ts | |
| F9 | Every owner and viewer pair resolves to Free Agent, owned by the viewer, or owned by another team, and the article page shows which | contractLifecycle.spec.ts · frontend: articleDetail/useArticleOwnership.spec.ts | |
| F10 | Changing formation benches a contract that no longer fits a position rather than dropping it | frontend: formation/lineupMutations.spec.ts · integration/lineup.integration.test.ts | |
| F11 | A league whose season is over still resolves, and a league is deleted only when its last member leaves | integration/joinByCode.integration.test.ts · integration/leagueLifecycle.integration.test.ts | |
| F12 | A report files a GitHub issue carrying the pseudonymous player id and never an email, and a failure falls back to a pre-filled link without leaving the app | integration/problemReport.integration.test.ts · integration/reportsRoute.integration.test.ts · frontend: useProblemReport.spec.ts | |
| F13 | A turn returns a question and the surviving candidate ids, and the prompt refers to candidates by id alone | articleGenie.spec.ts · integration/genieRoute.integration.test.ts | That a generated question never names the article rests on the prompt and the ids-only response, since the model is stubbed in tests |
Backend paths are under backend/src/tests/, frontend ones under frontend/src/tests/, and the Kotlin ones under scoring-collector/src/test/.
These are the non-functional requirements: the properties the architecture was actually shaped by, as opposed to what it does. Each one names the mechanism that enforces it, because a quality attribute with no mechanism is an aspiration.
The obligation. The system must be able to change database without rewriting its business logic.
The mechanism. Every persistence contract is an interface under repositories/, each store is one implementation beneath it (repositories/d1/ and repositories/mongo/), and composition.ts is the only module allowed to choose one, from a binding. The rule is enforced by ESLint: nothing under services/, routes/ or tests/ may import either implementation directory, so the seam cannot erode by accident.
The evidence. This is the one quality attribute here that has been discharged rather than argued. MongoDB was added as a second target with no change above the repository layer, and the conformance suite in tests/repositories/conformance, written against the interfaces and nothing below them, became its acceptance criteria unchanged. Both targets run the same suite on every ./gradlew check, so the second implementation cannot rot while the first one is the one being used.
What the exercise cost is worth recording, because it is what a portability claim usually hides: the interfaces held, and everything that had to be re-derived was a guarantee the relational store had been giving away for free (single-statement atomicity, a cascade on delete, a view). Each one is now stated explicitly on the document side, and named in the data model.
→ Persistence Targets · Backend Architecture
The obligation. Re-running a night must produce the same standings.
The mechanism. Ingest is an idempotent upsert keyed on (teamId, date), and the formation used for a day is frozen into the performance row as an immutable snapshot rather than read back from the current lineup. A player who rearranges their team today cannot change what they scored yesterday.
The obligation. A scoring rule must not be implemented twice.
The mechanism. The collector is a JVM process and the backend is a TypeScript Worker, so the temptation to compute points on both sides is real and permanent. It is closed off by contract: the collector posts raw facts only, and model/scoring.ts is the sole implementation of the curve.
The obligation. A handful of players must cost nothing at all, and a great many must cost something that rises with them rather than ahead of them.
The mechanism. Almost nothing is always-on and almost nothing is bought by capacity: a Worker rather than a server, GitHub Actions rather than a scheduler that has to be running in order to schedule.
The database is the one place that argument is split, and it is worth stating plainly rather than smoothing over. D1, which the Cloudflare deployment runs on, is billed by rows read and written, the same shape as the Worker. A MongoDB cluster is capacity bought in advance: free at the size the game is played at, and the first line of the bill that would become a decision if it grew. The repository seam is what keeps that a deployment choice rather than an architectural one.
At the numbers the game is played at today the whole system lands inside the free tiers with room to spare. Past them, every line of the bill but the cluster is per request and per row, so it follows the league count up a slope rather than a staircase: there is no size at which the architecture has to be bought again.
The one quantity that does not grow with players is the nightly fan-out to Wikimedia, which grows with distinct articles instead. It is throttled, and it is deduplicated by article across every team in every league, so a thousand squads holding the same article cost one request. That number is costed explicitly rather than assumed away.
The obligation. A rule must be testable without a browser and without a network.
The mechanism. Tiers with an explicit rule about which layer each may name, a seeding helper that goes through the production write path instead of raw queries, and a real database reset before every backend test: every collection emptied and re-seeded on MongoDB, the schema dropped and the migrations replayed on D1.
The obligation. Someone should be able to run the whole thing without obtaining a single credential.
The mechanism. ./gradlew noGenie against published images, a dev sign-in route that refuses to exist outside the local environment, and MSW standing in for the API in the browser. Only the Article Genie asks for a credential, and only the two tasks that name it.
→ Running FantasyWiki in Docker
| Constraint | Consequence |
|---|---|
| Wikimedia's API etiquette | The nightly fan-out is throttled and identifies itself with a user agent; the collector holds no persistent state |
| Pageviews are published in arrears | Scoring can only ever be a batch over a completed UTC day |
| Cloudflare Workers CPU budget | Ingest is chunked, and heavy work is pushed into a Workflow rather than a request |
| A Worker owns its sockets per request | The MongoDB client is built per request and never cached in a module. A cached one breaks every request after the first, and does it silently |
| Mongo transactions are snapshot-isolated, not serializable | A guarded write also writes the league it is guarding against, so the losing transaction is retried rather than committed against a stale snapshot |
| Multi-document transactions need a replica set | Even a local run is a single-node replica set, and the test suite starts one of its own |
The MongoDB driver reaches for node:net and node:tls | No Cloudflare deployment runs on MongoDB: the driver is aliased out of the Worker bundle, and the Mongo target has a wrangler config of its own |
| SQLite via D1 | ALTER TABLE cannot add a NOT NULL UNIQUE column, which is visible in more than one migration |
| AGPL-3.0 | The deployed service must carry its source link |
This page is a mapping, not a rule book. It names which Domain-Driven Design archetype each concept in FantasyWiki was modelled as, and which module holds it. Every rule it refers to is stated once elsewhere, under domain/ in the documentation index, and linked from here.
It exists because the codebase applies the archetypes without naming them. The repository interface with one implementation per store is an anti-corruption layer; the import rule that keeps services away from those implementations is layering enforced in the build. Both were built for their own reasons and are documented under those reasons. A reader who arrives looking for the vocabulary finds the practice and not the word, so this page supplies the word and points at the practice.
The model also has a perimeter, and the perimeter is a design decision like any other. Scope of the model names the archetypes the domain did not call for, and the constraint that made each one unnecessary.
CONTEXT.md CONTEXT.md is the glossary. It is what the code names its types after, what a commit message says, and what these documents call the thing.
Two of its conventions are doing DDD work:
_Avoid_ list of the near-misses the term exists to displace: Top Read Snapshot avoids "live ranking", Normalized Views avoids "adjusted views". A synonym that has been written down and rejected cannot quietly come back as a second name for one concept._Core_. marks the terms nothing else can be read without. Those are the terms the single-document exam report carries, so a reader going straight through it meets the vocabulary it is written in rather than the whole ingestion pipeline.The glossary is the artefact, and the discipline is that a term is added there before it is added to a type name. A word that appears in code and not in CONTEXT.md is the failure this arrangement prevents.
| Concept | Archetype | Lives in |
|---|---|---|
| League | Aggregate Root | model/league.ts, leagueRepository.ts |
| Team | Aggregate Root | model/team.ts, teamRepository.ts |
| Contract | Entity, inside the Team aggregate | model/contract.ts, contractRepository.ts |
| Player | Entity | model/player.ts, playerRepository.ts |
| Lineup / Formation | Entity, inside the Team aggregate | model/lineup.ts, model/formation.ts |
| Contract Term | Value Object | model/contract.ts |
| Chemistry Link, Chemistry Level | Value Object | model/enums.ts |
| Language Scale Factor | Value Object | model/languageScale.ts |
| Performance | Value Object | model/performances.ts |
| Article Availability | Value Object | model/contract.ts |
| Top Read Entry | Value Object | external-apis/wikimedia/wikimedia.ts |
| Invitation Code | Value Object | model/league.ts, as a validated string |
| Notification | Domain Event notification | model/notification.ts, notificationRepository.ts |
| Every business operation | Service | backend/src/services/ |
| Every persistence contract | Repository | backend/src/repositories/*.ts |
The discriminator is identity, not shape. Both model elementary concepts; what separates them is whether the domain distinguishes one instance from another. A Contract is an Entity because two contracts on the same article at the same price are still two contracts. A Contract Term is a Value Object because two windows over the same dates are the same window.
The consequences are visible in model/:
ContractTerm is read-only, and every lifecycle question about it is a pure function over it, termDays, remainingDays, isActive, isExpired. None of them mutates.Contract carries settled, renewalCount and renewalElected, all of which change over its life while its id does not.Identifiers are strings, and that is a deliberate boundary. DDD can model an Entity's identifier as a Value Object of its own, so the type system tells a team identifier from an article identifier. In model/ every identifier is a bare string:
export interface Contract {
id: string;
teamId: string;
articleId: string;
// ...
}The trade is a known one. Branded types in model/ would make the compiler the enforcer, at the cost of a wrapper on every identifier crossing the DTO and persistence boundaries — where the value has to be a plain string again. With one bounded context and one team, the mix-up that branding prevents has not happened; the upgrade path stays open because the identifiers are already centralised in model/.
Components of one aggregate do not hold references to components of another. The exception is a reference to the other aggregate's identifier, and it is the exception FantasyWiki relies on: a Team holds a leagueId, not a League. Expanding that identifier into a nested object is the DTO layer's job, never the model's. This is stated in What Are Model Entities and Shared DTO Package.
An aggregate root also guarantees the consistency of what it contains, and the Team aggregate's core invariant is its credit balance. ADR 0007 records where that invariant is enforced and why the placement looks wrong: the principle says the invariant belongs to the aggregate, and the purchase check lives in the INSERT instead, because the reading-then-writing alternative is not merely less tidy but incorrect under concurrent buys. Read that ADR before moving the check.
Of the four model-integrity patterns, the one FantasyWiki implements is the anti-corruption layer, and the upstream it defends against is the store.
backend/src/repositories/*.ts holds contracts expressed in domain terms. Underneath, repositories/d1/ speaks SQL and repositories/mongo/ speaks documents, pipelines and transactions. A store's own error wording never leaves the layer. composition.ts is the only module that names an implementation, and the boundary is enforced mechanically rather than by convention: no-restricted-imports forbids anything under services/, routes/ or tests/ from naming repositories/d1/** or repositories/mongo/**.
That an anti-corruption layer exists is a claim; that this one holds is a measurement. MongoDB was added without a change above the repository layer, and the conformance suite the first target passed became the second's acceptance criteria unchanged. See Persistence Targets.
composition.ts is also the codebase's one Factory in the DDD sense: repositoriesFor(env) selects the adequate implementation dynamically while hiding the choice from every caller.
There is a second anti-corruption layer, against a different kind of upstream. The store is a technology chosen by this project; the Wikimedia APIs are a web service nobody here controls, whose response shapes can change without notice. external-apis/wikimedia/ is the boundary: raw API rows arrive as WikimediaTopReadArticle and leave as TopReadEntry, a Value Object named in the Ubiquitous Language, and no caller sees the shape in between. That the layer is shared by the Worker and the browser is what makes it one boundary rather than two that drift. See Wikimedia Client Architecture.
The hexagonal arrangement is that outer layers depend on inner ones and never the reverse. FantasyWiki's modules map onto it as follows, with the domain layer at the centre because model/ imports nothing from a framework and so a Worker and a browser can both use it.
Rendering diagram…
Two things make this more than a drawing:
model/ and dto/ are separate packages both the Worker and the SPA depend on, and scoring-collector/ is a JVM module that shares neither: it is given an HTTP contract and a bearer secret, and that is all it is allowed to know.An archetype is a tool for a pressure. Naming the pressures this domain does not have is what makes the archetypes it does use load-bearing rather than decorative, so each of these is a decision with the constraint that settled it.
repositoriesFor(env). Domain objects are constructed from plain object literals and validated by predicate functions such as isSchema and isLeagueDuration. Creation is total and cheap: there is no multi-step assembly to encapsulate, so an encapsulating type would add a layer without removing one.Notification is a domain event made durable, written by the service that causes it. Publish-subscribe earns its cost when the producer must not know its consumers; here there is one consumer, the player reading their notifications, so the broker would be infrastructure with nothing to route. What a notification may and may not do is in Notifications.Status: decided and implemented (#503). Migration
0006_team_credits_view.sqlintroduces theteam_creditsview; the four repositories that previously re-derived the balance now read it, and so does the guarded purchaseINSERT.
A team's credit balance is the Team aggregate's core invariant. This ADR records two decisions about it that look, at first glance, like violations of the layering rules the rest of the codebase follows, and why both are deliberate.
Credits are derived from the contracts ledger on every read, never stored.
credits = STARTING_CREDITS − Σ purchasePrice + Σ salePayout (where settled)The derivation is stated once, as the team_credits SQL view. Every read path joins it.
Affordability is enforced inside the purchase INSERT, not in the service layer, the same statement that writes the contract also checks the balance and the contract cap.
model/team.ts mirrors the rule as deriveCredits(), a pure function. It does not enforce anything; it is the readable, unit-testable statement of what the SQL means.
A stored teams.credits column is a second copy of a number that the contracts ledger already determines. Two copies can disagree, and when they do the disagreement is silent and unrecoverable, there is no way, after the fact, to tell which one is right. Migration 0005_derive_team_credits dropped the column for exactly this reason, adding contracts.salePayout so the ledger holds every term of the formula.
The cost is a GROUP BY per read. At this scale it does not matter: a team holds at most MAX_TEAM_CONTRACTS (22) contracts, the lookup is an indexed one on idx_contracts_teamId, and FantasyWiki's unit of play is a private league of friends, not a global market.
This is the one place where we knowingly trade a DDD principle for a concurrency guarantee. The principle says an aggregate's invariants belong to the aggregate: the Team should decide whether it can afford a contract. Our repository decides, in SQL.
The reason is that the obvious alternative is incorrect, not merely less tidy. Read the balance in the service, compare it to the price, then write the contract, and two concurrent buys interleave like this:
| Request A | Request B | |
|---|---|---|
| 1 | reads credits = 100 | |
| 2 | reads credits = 100 | |
| 3 | 100 ≥ 80 ✓ | |
| 4 | 100 ≥ 80 ✓ | |
| 5 | writes contract (80) | |
| 6 | writes contract (80) |
Both checks passed against the same stale read; the team has now spent 160 of its 100 credits. The identical race applies to MAX_TEAM_CONTRACTS, which is why that check sits in the same statement: two concurrent buys at 22 contracts would both see 22 and both commit, giving the team 24.
Cloudflare D1 offers no interactive transaction that would let the service hold a read and a write together. What it does guarantee is that a single statement is atomic against concurrent writers. So the check and the write have to be one statement, and a statement is SQL. The guarded INSERT is not a shortcut around the domain model, it is the only construct available that makes the invariant actually hold.
Do not "fix" this by moving the check into the service layer. Doing so would reintroduce the race above, and no unit test would catch it.
The enforcement location was never the problem. The problem was that the formula had been hand-copied into four separate repositories, contractRepositoryD1 (three sites, including the INSERT), teamRepositoryD1, notificationRepositoryD1, performanceRepositoryD1, as SQL the domain model could not see. Four copies of a rule is four chances to update three of them.
The team_credits view collapses those to one. It is team-anchored (teams LEFT JOIN contracts) rather than contract-anchored, so a team with no contracts still appears at the full starting budget and callers need no COALESCE fallback of their own.
The view is used by the guarded INSERT too, so the formula now has exactly one SQL statement across both reads and the write. This is safe on both counts that matter:
INSERT is still one statement.EXPLAIN QUERY PLAN confirms SQLite pushes the teamId predicate into the view, SEARCH t USING COVERING INDEX sqlite_autoindex_teams_1 (id=?) and SEARCH c USING INDEX idx_contracts_teamId (teamId=?). It is still an indexed lookup, not a scan of the whole contracts table.A SQL view takes no bind parameters, so it cannot receive STARTING_CREDITS from model/team.ts, the migration inlines 1000 as a literal. The duplication is deliberate, and it is pinned by a test rather than by the type system: backend/src/tests/repositories/d1/teamCreditsView.d1.test.ts asserts that a team with an empty ledger reads back exactly STARTING_CREDITS. Change the constant without changing the migration and that test fails. It sits in the D1 tier because the literal is D1's, and so is the COALESCE over a NULL salePayout that the same file pins.
That every read path returns one balance, and that deriveCredits() agrees with whatever computes it, are rules about the answer rather than about the view, so they live in backend/src/tests/repositories/conformance/derivedCredits.integration.test.ts, where a second persistence implementation has to keep them too. That agreement is the whole reason the pure function exists: the storage enforces, the function documents, and the conformance suite is what keeps them the same rule.
teams has no credits column and must not regain one.team_credits. It does not write the formula out.salePayout is NULL contributes its purchase and no payout. The view's COALESCE handles it and a fixture row pins the behaviour.How a change reaches production: the branches sized for two authors, the commit convention the release is computed from, the one gate every merge passes, how a version is cut and what it names, and the licence the work is published under.
How a change gets from an idea to master: the branches it lives on, the shape its commits must have, and the one gate every change passes. What happens after master, the deploy and the version, is in Deploy Strategy and Release Process.
The process is sized for what the project is: two authors, one deployed service, one production version at a time. Every rule below is there because it pays for itself at that size, and the heavier conventions a larger team would reach for are left out on purpose, with the reason.
| Branch | Lives | Is for |
|---|---|---|
master | forever | Production. Every push deploys it and may release it. |
dev | forever | Integration and QA. Every push deploys it to the preview environment. |
<type>/<topic> | days | One change. <type> is the Conventional Commits type it carries: feat/, fix/, docs/, refactor/, ci/, chore/. |
renovate/* | hours | One dependency update, opened by Renovate. |
A topic branch is merged into dev when it needs to be seen next to the other author's work on a deployed environment first, and straight into master when it does not. dev reaches master through a pull request like any other branch.
Why not GitFlow. Its release/* and hotfix/* branches exist to prepare and patch versions that are shipped and supported side by side. This project ships one version, the one running in production, and never patches an older one, so those branches would be ceremony with nothing to protect. What GitFlow gets right, an integration branch that is exercised before production, is kept, and it is dev.
Why not trunk only. With two people changing the same screens in the same week, a shared environment that is not production is where one author's change meets the other's before a player does. That is the one job dev has, and it is why it deploys.
Every commit follows Conventional Commits: feat(frontend): reveal the podium while the season is still running. The type is not decoration. It decides the next version (Release Process), it names the branch, and it sorts the release notes.
It is enforced where a commit is written: a commit-msg hook, installed by the Gradle build itself (settings.gradle.kts), rejects a subject that does not parse. There is no step to remember, a first ./gradlew run installs it.
The hook has one blind spot, and it has been hit. A squash merge on GitHub takes its subject from the merge dialog, where no local hook runs. That is how Feat: Implement the team dashboard reached master on 2026-03-23, with a type the release tooling does not recognise. The pull request template asks for a Conventional Commits title for that reason, and the one who merges is the check.
One commit is one change. A pull request whose history is a series of attempts is squashed; one whose commits are each a finished change is rebased, and the commits land as they are.
master master is guarded by a repository ruleset, not by convention:
| Rule | What it stops |
|---|---|
| A pull request is required | A change nobody but its author has seen |
ci-cd / success must pass, on a branch up to date with master | A change that is green only against an older master |
| Merge by squash or rebase only; linear history | A merge commit, see below |
| Signed commits | A commit whose author cannot be verified |
| No force push, no deletion | Rewriting what production was built from |
ci-cd / success is the aggregate of check.yml: format, lint, typecheck, audit, and every test suite on both persistence targets (Continuous delivery).
The ruleset asks for no approval, and that is deliberate at this size. A mandatory second review would stop whichever author is working while the other is not, and the checks carry the part of review a machine can do. Review by the other author happens on the pull request when the change calls for it, and the tooling does not force it: of the last 25 pull requests other than Renovate's, 10 carry a formal review and 23 a discussion.
Why no merge commits. master's history is the input to the release: the version is computed from its commits and the notes are its commits, grouped. A merge commit adds an entry that is not a change, and a branch's commits interleaved with master's make a release's contents depend on commit dates rather than on what was merged. A linear master is also one git bisect can walk without being told which parent to follow.
Pull requests from forks run check.yml like any other: the workflow names no secret, and the jobs that need one either skip a fork or run only on a deploying branch. See the dispatcher in .github/workflows/ci-cd.yml.
Every dependency is declared: package-lock.json locks each npm project, and the JVM module's direct dependencies are pinned in Gradle version catalogs. Renovate opens a renovate/* branch per update and merges it once ci-cd / success passes. The shared preset it extends holds an npm release back three days first, npm's window for retracting one. Merging updates nobody watched is acceptable for one reason only: the gate is the whole test suite on both persistence targets, and an update that breaks an interface breaks a test.
A version is computed, never typed. Nobody decides that the next release is 1.4.0; the commits merged since the last one decide it, by a rule written down once in release.config.mjs. There is no release branch, no release checklist, and no command to run: merging into master is the whole procedure.
One version names the whole system at one commit: the SPA, the Worker and the scoring collector image. They are built, checked and deployed from the same commit on master, so a version per component would only be three names for one fact.
A version is cut after the production deploy succeeds, so a version is what production is running. A revision that failed to deploy is not released; its commits go out with the next release that does deploy.
.github/workflows/release.yml runs semantic-release on every push to master, after the deploy. It reads the Conventional Commits since the last tag and applies the course's shared preset, semantic-release-preconfigured-conventional-commits, unmodified:
| Commits since the last release include | Release |
|---|---|
a ! after the type, or a BREAKING CHANGE: footer | major |
a feat | minor |
a fix, perf, docs or revert | patch |
only chore, ci, build, refactor, test, style | none |
When there is something to release it does three things and nothing else:
1.4.0, no v, the form gitSemVer in the Gradle build also reads.publish-images.yml, which adds it as a tag to the collector's container image on GHCR beside latest and sha-<short>. The image and the notes that describe it share a name.It writes no CHANGELOG.md. A changelog file only means something if it is committed back to master, and master accepts pull requests and signed commits only. The notes live on the release, where a reader looks for them.
The version fields in the package.json files are not the release version. None of those packages is published, and nothing reads the field as a version of FantasyWiki; the tag is the only place a version is written.
Everything about a release is decided by the commits, so the lever is the commit type, chosen when the commit is written:
feat or a fix, and it will be released.build, test, ci or chore, and it will not, even though it deploys.!. For the HTTP API that also means a new path version, see API Naming Rules.| Version | Landed on master | Milestone |
|---|---|---|
| 0.1.0 | 2026-02-26 | The app shell and the landing page |
| 0.2.0 | 2026-04-29 | The first playable screens: dashboard, formation, leagues on the backend |
| 0.3.0 | 2026-06-02 | Real Wikipedia data, Chemistry Links, player accounts |
| 0.4.0 | 2026-07-14 | The contract economy, and in-app problem reports |
| 0.5.0 | 2026-07-28 | Nightly scoring on the JVM collector; the beta opens |
| 0.6.0 | 2026-08-14 | Built during the beta: league detail, the Article Genie, rival line-ups |
| 0.7.0 | 2026-08-22 | The league section, every Wikipedia edition, the app in Docker |
| 0.8.0 | 2026-08-31 | MongoDB, password sign-in, WCAG 2.2 AA, the documentation site |
| 0.8.1 | 2026-09-18 | Documentation |
Why 0.y.z. SemVer reserves major version zero for initial development, when anything may change, and that is what these months were: a game whose rules were still being found, and from 28 July played in a beta by one league of friends.
Why 1.0.0 is the API. SemVer says 1.0.0 defines the public API. The first breaking change after 0.8.1 is the one that moved the HTTP API under /api/v1, so the release that follows it is 1.0.0, and the version that declares the API stable is the one that versions it.
Status: decided when the repository was created (2025-12-14), recorded 2026-09-21.
FantasyWiki's source is published under the GNU Affero General Public License v3.0 (LICENSE). Anyone may run, study, change and redistribute it, and anyone who offers a modified FantasyWiki to players over a network must offer those players the modified source.
FantasyWiki is not distributed; it is served. Nobody installs it. A player opens a page, and the code runs on Cloudflare. That one fact decides between the licences, because the obligations of most of them are triggered by distribution, and a hosted game never distributes anything.
| Licence | What a third party hosting a modified copy owes its players | Verdict |
|---|---|---|
| MIT / Apache-2.0 | Nothing | Rejected: a closed fork of an open game is allowed |
| GPL-3.0 | Nothing, because serving is not distribution | Rejected: copyleft in name, permissive in practice for a web service |
| AGPL-3.0 | The modified source, to every player it serves | Chosen |
| Proprietary | Not applicable, nobody may host it | Rejected: the project is public and is meant to be read, studied and played with |
GPL-3.0 is the instructive rejection. It is the licence usually meant by "copyleft", and for this project it would protect nothing: a host could change the scoring, the economy or the data it keeps about players and never publish a line, because no copy of the program ever leaves its servers. The AGPL's section 13 closes exactly that gap, and it is the only difference between the two that matters here.
frontend/src/layout/InfoFooter.vue); removing it would put the deployment itself out of compliance. It is listed as a constraint in Requirements.The system in context, containers and layers; the boundary that let a second persistence target arrive without a change above it; and the two runtimes the project technically involves, with the decision record that explains why the second one exists.
FantasyWiki is a monorepo with five deployable or publishable pieces and two shared type packages, orchestrated by Gradle over npm. This page is the map. The rules each piece implements live under domain/; the detail of how each is built lives under architecture/.
Who talks to the system, and what it talks to.
Rendering diagram…
Five runtimes. Note what does not connect: the collector has no database credential, and the frontend never talks to the database at all.
Rendering diagram…
The store inside the Cloudflare box is the one production runs on. The box beside it is the same system persisted somewhere else entirely: the backend holds repository interfaces, and MongoDB is a second implementation of every one of them, run locally rather than deployed. Which implementation a build gets is decided in a single module, below.
| Container | Runtime | Deployed by | Documented in |
|---|---|---|---|
| Frontend | Vue 3 + Ionic SPA | Cloudflare Pages, per branch | Frontend |
| Backend | Hono on a Cloudflare Worker | Wrangler, per branch | Backend Architecture |
| D1 | SQLite at the edge, reached through a binding | Migrations replayed on deploy | Data model |
| MongoDB | A replica set, reached over the driver — the second target, not deployed | Indexes and baseline on first connection | Persistence Targets |
| Settlement Workflow | Cloudflare Workflows | Bundled with the Worker | ADR 0003 |
| Scoring Collector | Kotlin/JVM, application plugin | GitHub Actions cron + GHCR image | Scoring Pipeline |
Two of the seven directories exist purely so the other five cannot disagree with each other.
Rendering diagram…
model/ holds normalised, framework-free entities, what a Contract is, independent of how it is stored or sent. It imports nothing from a framework, so both a Worker and a browser can use it. → What Are Model Entities
dto/ holds the wire shapes, what the API sends, which is deliberately not the same as what the domain contains. DTOs aggregate and nest; entities stay normalised. Each side "dresses" the domain for its own purpose. → Shared DTO Package · DTO Dressing Pattern
The collector shares neither. It is a JVM process, and giving it generated types would couple a compiled module to a TypeScript build. It gets an HTTP contract and a bearer secret, and that is all it is allowed to know.
Three layers, each talking only to the one below it.
Rendering diagram…
The interesting line is the dotted one. composition.ts is the single place that decides which store the system is running on, it reads a binding and returns one set of implementations, and the rule is enforced mechanically: no-restricted-imports forbids anything under services/, routes/ or tests/ from naming repositories/d1/** or repositories/mongo/**.
That the second implementation exists is what makes this more than an intention. MongoDB was added without a change above the repository layer, and the conformance suite the first target passed became the second's acceptance criteria unchanged. → Persistence Targets
Services may call other services, and are encouraged to: a rule implemented twice is a rule that will eventually be two different rules.
→ Backend Architecture · Backend Error Constants
Each of these is a place where the code was deliberately cut so that one side can change without the other noticing.
| Seam | What it separates | Why |
|---|---|---|
composition.ts | Business logic from the database | So the store can be replaced without touching a service, and a second one was (the layers, above) |
/internal/* | The scoring engine from the game | The collector computes nothing and knows no rules (pipeline) |
DraftLineup | Editing a formation from saving one | Pure mutations, testable without a server (lineup editing) |
buildArticleDetail | Article facts from viewer context | Ownership is resolved once, asynchronously (ownership resolution) |
| Wikimedia client capabilities | Transport from behaviour | A capability is added without touching the composition root (client architecture) |
| Query keys module | Cache identity from call sites | One module owns every TanStack key (query keys) |
The backend is a Cloudflare Worker built with Hono. It follows a layered structure:
backend/src/routes)backend/src/services)backend/src/repositories), with one implementation per store under repositories/d1/ and repositories/mongo/Shared domain models are in the top-level model/ package and are reused across frontend and backend.
FantasyWiki/
├── model/
├── backend/
│ ├── migrations/
│ └── src/
│ ├── index.ts
│ ├── routes/
│ │ ├── auth.ts
│ │ ├── leagues.ts
│ │ └── session.ts
│ ├── services/
│ │ ├── login.ts
│ │ ├── player.ts
│ │ ├── leagues.ts
│ │ └── wikimediaClient.ts
│ ├── composition.ts
│ └── repositories/
│ ├── playerRepository.ts
│ ├── result.ts
│ ├── d1/
│ │ └── playerRepositoryD1.ts
│ └── mongo/
│ └── playerRepositoryMongo.ts
└── frontend/backend/src/routes) backend/src/services) PlayerRepository) rather than route concernsResult values consumed by routesA service may, and is encouraged to, call another service when that service already provides something useful. Reuse beats restating: a rule implemented twice is a rule that will eventually be two different rules. Take the dependency through the constructor like any repository, so tests can substitute it, and let the caller's Result carry the callee's failure outward rather than re-wording it.
The rule is one of preference, and it holds even when all you want is the data: when what service A needs is offered both by service B and by repository C, reach for B. Go to C only when there is no such B. A service's read is rarely only a read, it dresses rows into DTOs, fills in derived fields, applies the rules that decide what counts as absent, and calling it means you inherit those, including the ones added after you wrote the call. Reaching past it to the repository buys one fewer hop and gives up all of that.
One limit keeps this from becoming a tangle: keep the dependencies acyclic. If A calls B, B must not call back into A. A cycle usually means the shared part wants to be its own service (or to move down into a repository) rather than to be reached for in both directions.
backend/src/repositories) playerRepository.ts)repositories/d1/ and repositories/mongo/A deployment runs on exactly one store, and composition.ts is the only module that picks it. See Persistence Targets.
backend/src/index.ts)db bindingbackend/migrations/cd backend && npm run test (D1), npm run testmongo (MongoDB)composition.ts chooses an implementation, is in Backend TestingThe backend runs on Cloudflare D1, SQLite at the edge. It is what production and preview deploy to, and it is the only store any deployment has ever used.
MongoDB is a second implementation of the same repository interfaces, run locally and never deployed. It is not a fallback and not a migration in progress: it exists so that the boundary between the domain and its storage is demonstrated rather than asserted. Nothing above repositories/ knows which of the two it is talking to, services and routes are handed the Repositories interfaces, and the same integration suite runs against either on every ./gradlew check.
That is what keeps the store a decision instead of a dependency. A system with one implementation of a storage interface has an untested claim; this one has a second store that passed the first one's conformance suite unchanged, so moving to whichever offer is cheaper later is work that has already been rehearsed once, rather than a rewrite discovered at the worst moment.
backend/src/composition.ts is the only module that names an implementation of the domain repositories, and a binding is what it reads. (The credential store username/password sign-in uses is composed separately, by src/passwordComposition.ts, and only in the build that has it, see Auth Modes.)
| Binding | D1 deployment | Mongo deployment |
|---|---|---|
PERSISTENCE | absent | mongo |
db | the D1 database | absent |
MONGO_URL | absent | connection string |
MONGO_DB | absent | optional, overrides the database in the URL's path |
repositoriesFor(env) is synchronous, and has to stay that way: the request middleware, the settlement Workflow and the test seam all call it without awaiting. So the Mongo repositories are built around a target and open their connection on the first call that needs one, and hold it for as long as they may, which is the request, not the isolate. See One connection per request below, which is the part of this to read before changing any of it.
No Cloudflare deployment runs on MongoDB. Production and preview are D1, and the Mongo target runs locally. Keeping it that way took more than good intentions: composition.ts names both targets, so the driver is reachable from the Worker's entry point, and making the import dynamic is not enough, esbuild follows dynamic imports and inlines them. Measured on the production dry-run, that shipped 1.5MB of driver (2722 KiB total, 446 KiB gzipped) with a Worker that can never use it, and the build failed without nodejs_compat, because the driver imports net, tls and child_process.
So wrangler.jsonc aliases mongodb to repositories/mongo/driverAbsent.ts, which brings the production bundle back to 962 KiB / 220 KiB gzipped, 8 KiB gzipped above the pre-MongoDB baseline, that being the repository code itself, which is small and unreachable. No compatibility flag is needed anywhere in wrangler.jsonc, so the D1 deployments are configured exactly as they were.
Two things make that safe rather than clever:
alias is a top-level wrangler key: it is ignored inside an environment, so it covers every wrangler build, which is the right scope when no deployment runs on MongoDB.npm run testmongo exercises the real driver.The layering rule is mechanical. no-restricted-imports in backend/eslint.config.ts forbids both repositories/d1/** and repositories/mongo/** to services, routes and tests, exempting only tests/repositories/<target> and tests/support/<target>.
The Mongo driver runs inside workerd, that is how the suite runs it. Two things make that work, and both live in vitest.shared.ts, deliberately rather than in wrangler.jsonc:
compatibilityFlags: ["nodejs_compat"], passed to Miniflare for the Mongo run only. The driver reaches for node:net, node:tls, node:crypto and friends. It is not in wrangler.jsonc because env.test is a mirror of production, and a compatibility flag, which changes how dependencies resolve and what globals exist, is exactly the kind of difference that would stop it being one.mongodb for the test pool. The driver is CommonJS, and the Workers Vitest pool cannot serve a require("node:x") from inside a CommonJS module; deps.optimizer.ssr converts it to ESM, with the Node builtins in exclude so they stay imports the runtime resolves rather than files rolldown tries to read from disk.MongoDB must be a replica set, Atlas always is, and locally a single-node one is a flag. Multi-document transactions need it, and the guarded writes below are transactions. The test suite starts one of its own, which is what lets ./gradlew check run against both targets on any machine (Backend Testing).
alias has no per-environment form, so it applies to wrangler dev on wrangler.jsonc too. That is why the MongoDB run has a config file of its own, backend/wrangler.mongo.jsonc, no alias, nodejs_compat on, PERSISTENCE and MONGO_URL set, and no D1 binding at all:
npm run devmongo # or ./gradlew devMongo, with the frontend beside itIt also names a different main, src/indexPassword.ts, which is what gives this run username/password sign-in and the deployed one none of it. main being per-file is what makes that possible, and the reasoning is the mirror of the alias argument above: see Auth Modes.
A file rather than a fifth environment in wrangler.jsonc, for two reasons. It is not a deployment, nothing on Cloudflare runs on MongoDB, and the file that deploys should not carry a configuration that can never be deployed. And it could not be an environment anyway: the alias it needs switched off is top-level-only.
It binds less than env.local does, on purpose: no D1, and no settlement Workflow or cron, that one being started by a daily trigger which does not fire locally. It keeps the rate limiters, which the join and report routes call unconditionally, and it keeps AI, so the Article Genie is the real thing here. Workers AI has no local simulator, so that binding is marked remote: true, a Genie call leaves the machine and spends the account's allocation even in dev. The server starts without Cloudflare credentials all the same; only a Genie call needs them, and it fails as ASLEEP rather than stopping the run.
A Worker owns its I/O objects per request: a socket opened while handling one request may not be touched by the next. The MongoDB driver's pooled connection is exactly such a socket, so a MongoClient cached in a module variable serves the request that opened it and then breaks every request after, and it does not break loudly. The driver never reports an error; the promise simply never settles, and workerd eventually kills the request with "your Worker's code had hung and would never generate a response".
So a client's lifetime is a request's lifetime. MongoStore holds one and repositoriesFor builds one store per request, which gives each request a single connection shared by all its repository calls. Nothing closes them, sockets are reclaimed when the request context ends.
Two consequences worth carrying:
wrangler dev. If you change how the connection is held, exercise it with two requests.ContractSettlementWorkflow.run builds its service once and uses it across several step.do(...) calls, which is fine on D1 and would hit exactly this wall on Mongo. Nothing runs into it today, no Cloudflare deployment uses MongoDB, and the local config binds no Workflow, but a Mongo target on Workers would have to build its repositories inside each step.Collection names and field names mirror the D1 columns, so backend/migrations/ stays readable as the description of what is stored for either target. Ids are _id.
| Collection | Keyed by | Notes |
|---|---|---|
google_accounts | account id | googleId unique |
players | player id | username and accountId unique |
leagues | league id | invitationCode unique when present (partial index) |
teams | team id | (playerId, leagueId) unique, one row per player per league, ever |
contracts | contract id | indexed by teamId and by (settled, expireDate) |
notifications | notification id | |
performances | teamId:date | (teamId, date) unique |
lineups | team id | a team has at most one |
language_scales | language code |
Dates are stored as text in both targets, ISO-8601 instants for leagues, YYYY-MM-DD for contract terms, so a plain comparison is a chronological one.
There is no migration runner. Mongo has no schema to migrate, so a fresh database needs only the indexes and the baseline the product assumes exists (the Global League, its system admin, and the en scale factor, migrations 0002 and 0009). Both live in repositories/mongo/bootstrap.ts, both are idempotent, and they run on the first connection an isolate opens.
Credits are derived from the contracts ledger on every read and never stored (ADR 0007). D1 states the rule once as the team_credits view; Mongo states it once as teamCreditsStages in repositories/mongo/schema.ts, attached to the five reads that need a balance. Both agree with deriveCredits in model/team.ts, which is what the conformance suite checks them against.
Several repository contracts say the conditions are evaluated inside the write: the purchase conditions, the join gate, the departure, the league closure. D1 gets that from SQLite's single-statement atomicity.
Mongo has multi-document transactions, but they are snapshot-isolated, not serializable, two transactions that merely read the same league would both commit against a snapshot taken before a concurrent close. So every such transaction also writes the document it is guarding against, bumping leagues.revision. That puts the two in each other's write set, so the loser hits a write conflict and the driver retries it against the state the winner left.
revision doubles as join order. The value a joiner bumps it to becomes its team's seq, which is the seniority leave hands a league on by, the same thing D1 reads rowid for.
The grain is a league, which is coarse: two players buying different articles in the same league contend, and one of them is retried. Article Availability is league-scoped, so a league-scoped guard is the honest one, but the Global League holds every player in the game, so in practice that one document is where all of it lands. Nothing to do about it while the guard has to cover "no team in this league holds the article"; worth knowing before treating the Mongo target as something to run at scale.
Single-document guards need none of this and use none of it: settling a sale, settling an expiry, renewing, electing and closing are one conditional updateOne each, exactly as they are one conditional UPDATE in D1.
The one thing D1 gets for free and Mongo spells out is the cascade. Deleting the last member's league takes its teams, contracts, notifications, performances and lineups with it; D1 declares that as ON DELETE CASCADE, and TeamRepositoryMongo.leave deletes them inside the same transaction.
Nothing here was chosen because it is popular. Each entry names what it was picked over and the constraint that settled it, usually one of three: the system must cost nothing to run at the size it is played at, a scoring rule must exist in exactly one place, and a new contributor must be able to start it without obtaining a credential.
Rendering diagram…
Cloudflare Workers, over a container on a rented VM. The deciding property is that nothing is always-on: a league nobody opened today costs nothing, and there is no instance to keep warm between the nightly run and the morning. The price is a real one, a request has a CPU budget, which is why ingest is chunked and the settlement sweep runs as a Workflow rather than inside a request.
Hono, over Express or a bare fetch handler. Express assumes Node's http, which the Workers runtime does not have; a bare handler would mean writing routing and middleware. Hono is built for the runtime and its JWT and OAuth middleware are the two pieces of infrastructure this project would otherwise have written itself.
Cloudflare D1, over a managed database on a rented instance. It is what the deployment runs on, and the property that decided it is the one that decided the runtime: D1 is billed by rows read and written rather than by an instance sized in advance, so a league nobody opened today costs nothing and there is nothing to keep warm overnight. It also lives inside the platform the Worker already runs in, so a query is a binding rather than a connection held open across a network.
Its price is visible in the migrations. SQLite cannot add a NOT NULL UNIQUE column to an existing table, so several of them arrive in two steps, and the one derivation this system has — a team's balance — is a view over the contracts ledger rather than a column that could disagree with it. → ADR 0007
MongoDB is the second implementation, and it is what makes the first one replaceable. It is not what production runs: wrangler.jsonc aliases the driver away, so the deployed Worker neither carries it nor could reach it. It runs locally, against a single-node replica set, from its own wrangler.mongo.jsonc — a config kept separate because one that can never be deployed does not belong in the one that deploys.
It earns its place by being a genuinely different shape: documents instead of rows, an aggregation pipeline instead of a view, and transactions that are snapshot-isolated rather than serializable, which is why every guarded write also writes the league document it guards against. Nothing above the repository layer knows any of that.
Two targets rather than one is not hedging. It is the evidence for the claim the architecture makes everywhere else: every persistence contract is an interface, one module picks the implementation, and the same conformance suite runs against both on every ./gradlew check. A store that can be swapped in a test run is a store that can be swapped when the bill changes — which is what keeps the choice of vendor a decision rather than a dependency. → Persistence Targets · Backend Architecture · Data model
Vue 3 with Ionic, over plain Vue or React Native. The game is played on a phone more than a desk, and Ionic supplies the platform-shaped navigation and components for a web build that can also be packaged with Capacitor, without the second codebase a native framework would have meant.
Pinia and TanStack Query, deliberately as two different things. Pinia holds what the app knows about itself; TanStack Query holds what the server knows and owns the caching, refetching and invalidation that hand-rolled store actions get wrong. The split is the rule, and it is written down. → Frontend
MSW, so the frontend can be developed and tested with no backend running at all. It is also why the test suite can assert on a request that was never sent over a network.
Kotlin/JVM in a container, on a GitHub Actions cron, over doing the nightly work in the Worker. A night's scoring is thousands of Wikimedia calls under an etiquette limit, and a Worker invocation is allowed fifty subrequests — no paid tier removes that ceiling, and a Worker would be V8 again, so it would add no second runtime either. A scheduled job that exists only while it runs is the same always-on argument, one platform over; Actions is already where this repository's automation lives, so it costs no new account.
The rule that keeps this from splitting the game in two is that the collector computes nothing: it posts raw facts, and the single implementation of the scoring curve lives in the TypeScript model/ package. Two runtimes, one formula. → ADR 0004 · Nightly Scoring Pipeline
The split between the two platforms falls short of the guideline that the dominant one stay under three quarters of the project: the TypeScript side is most of the code, and the collector is a small module beside it. That is the consequence of a design decision, and it is the same decision that keeps the two platforms coherent.
The second platform was designed to be larger. The first version of ADR 0004, on 2026-06-21, had the nightly engine read contracts and write scores and standings straight to D1, and compute base points, chemistry and a weekly tournament itself, in a runtime of its own. Most of the game's scoring would have lived there.
It was cut in two steps, both on purpose. By 2026-07-13 the engine posted to two backend endpoints instead of writing the database, because a second writer would have to carry every rule about what a valid row is. Then the arithmetic went too. The scoring rules were already implemented in model/, in TypeScript, because the Worker prices contracts with the same curve, and a Kotlin engine that computed points would have been a second implementation of them, kept in step by hand and checked by golden vectors. On 2026-07-14 commit d1910f0 moved all of it to model/scoring.ts, deleted the Kotlin Scoring.kt, and left the collector a fetcher that posts raw facts.
The two platform criteria pull against each other here, and the project chose coherence. Core entities that span platforms should be defined so as to minimise duplication; a large second platform, in a system whose rules already live on the first, is duplication by construction. Keeping one formula in one language is what made the collector small, and moving code to the JVM to raise its share would bring back exactly the second copy the cut removed.
Work that needs a long-running process rather than a request, such as the daily challenges planned next, belongs on the JVM for the reason the collector does, and the share will move with it.
A Gradle-orchestrated monorepo over three separate repositories or a bare npm workspace. dto/ and model/ are consumed by both the frontend and the backend, so a change to a wire shape has to be able to fail both sides in one run, which it does, because one ./gradlew check builds everything. Gradle rather than npm workspaces because one of the four packages is a JVM project.
TypeScript everywhere it can be, including the shared packages, so that the API's shapes are checked at both ends of the wire rather than agreed by convention. What TypeScript cannot check, that the HTTP surface matches what is documented, is checked by a test instead. → OpenAPI spec
| What | Used for | Chosen over |
|---|---|---|
| Cloudflare D1 | Persistence in production, billed by rows rather than by an instance | A managed database on a rented instance, always on and always billed |
| A MongoDB replica set | The second persistence target, run locally, and the transactions its guarded writes need | Staying on one store, which would leave the repository boundary untested |
| Cloudflare Pages | Frontend hosting, per-branch previews | A static bucket with a CDN in front |
| Cloudflare Workflows | The settlement sweep, which outlives a request | A long-running request, which the CPU budget refuses |
| Cloudflare Cron Triggers | Starting the nightly sweep | An external scheduler that has to be running to schedule |
| Workers AI | The Article Genie's questioning | A hosted LLM API, which would be the project's only paid dependency |
| GitHub Actions | CI on every branch, deploys on master and dev | A CI service that has to be provisioned separately |
Vitest + @cloudflare/vitest-pool-workers | Backend tests in the real runtime | Node-based tests against a mocked runtime |
| Docker Compose | Running the whole stack with no credentials | A page of setup instructions |
| VitePress | This documentation site | A generated API-doc site with no room for prose |
Status: decided and implemented. The platform choice — a standalone Kotlin/JVM batch, autonomous from the Worker, as the project's second target platform — is what runs. Two mechanisms were revised between the decision and the implementation, the scheduler and the delivery path; both are recorded under Alternatives considered with the constraint that decided each. What runs in detail is Nightly Scoring Pipeline.
Decision. The daily scoring engine is a standalone Kotlin/JVM service, packaged as a container image published to GHCR and run once a night by a GitHub Actions cron. It reads its inputs from the backend over GET /internal/scoring-inputs, sources pageviews from the per-article Wikimedia Analytics API, resolves chemistry links against Wikimedia, and posts raw facts back over POST /internal/performances. It computes no points: the backend turns facts into scores through the single implementation in model/scoring.ts. It holds no database credential and no persistent state.
It is the SPE second target platform — a JVM runtime distinct from the all-V8/JS frontend and backend, and the repository's first compiled Gradle module.
This is the analysis that moved the batch off Cloudflare, and it is unchanged by everything that was revised later. The per-invocation Worker limits make the batch infeasible. The free "100k requests/day" allowance is a request count; a daily batch is 1 invocation/day. The binding limits are per-invocation:
| Limit (per invocation) | Free | Paid ($5/mo) | Needed by the batch | Source |
|---|---|---|---|---|
| CPU time | 10 ms | 30 s → 5 min; cron ≥1h → 15 min | fetch and score every league, daily | [1][2] |
| Memory | 128 MB | 128 MB (unchanged) | hold the working set | [2] |
| Subrequests | 50 | raised, not unlimited | one fetch per contracted article, plus the link graph | [2] |
| Requests/day | 100,000 | 10M/mo | 1 | [1] |
Subrequests are the binding constraint. A night's work is one range request per distinct contracted article, plus the link graph among the paired ones: ~150 calls at the scale the game is actually played at, ~6k at the ceiling this ADR budgets for. A Worker invocation is allowed 50, and the smaller of those numbers already exceeds it threefold.
No Workers tier fixes this: the free tier is impossible on CPU time alone (10 ms), the paid tier lifts CPU and subrequests but not the 128 MB ceiling and not to "unlimited", it costs money, and a Worker is still V8 — the same platform as the frontend and backend, so it would add no second runtime and satisfy none of the exam's platform requirement either.
A separate runtime, with no per-invocation ceiling and its own concurrency control, is the only shape that fits.
| Layer | Choice | Why | Source |
|---|---|---|---|
| Runtime | Kotlin / JVM | The first real compiled Gradle module in a repository where Gradle otherwise only shells out to npm; the build is already .kts, so one language spans build script and module. Coroutines give the throttled per-article fan-out. | , |
| Packaging | Container image on GHCR | publish-images.yml builds scoring-collector from docker/scoring-collector.Dockerfile on every master and dev. The host becomes a property of where the image runs, not of the code. | , |
| Scheduling | GitHub Actions cron, 0 5 * * * | Already the repository's CI home, so it adds no account, no IAM surface and no second place to look when a run fails. Free at this cadence. SCORING_RUNNER=external hands the schedule to something else without touching code. | , |
| Pageviews | Per-article Analytics (AQS) API, one range request per article | One request returns the whole 30-day daily history for an article, so ≤ ~6k distinct contracted articles cost ~6k requests/day, comfortably inside a nightly batch. Targeted, clean JSON, no bzip2 and no title matching. Pageview Complete dumps stay the scale-out fallback past tens of thousands of articles. | [3][4][8] |
| Delivery | /internal/* on the backend | The backend stays the only writer of its own database. See below. | , |
The collector could have held a database token and written its results directly. It does not, and the reason is an invariant rather than a convenience.
The backend is the sole writer of its own store. Every rule about what a valid performance row is — the scoring curve, the Language Scale Factor, the idempotency of a re-run — lives above the repository layer, in code the collector does not have. A second writer would either duplicate those rules in Kotlin, and they would drift, or write rows that satisfy none of them.
So the seam is two endpoints on backend/src/routes/internal.ts, mounted outside the /api/* JWT guard and behind Hono's bearerAuth, because the caller is a batch job rather than a person:
GET /internal/scoring-inputs?date=D hands back one row per team: the articles it fielded, the article pairs chemistry is resolved over, and an opaque formation snapshot the collector echoes back untouched.POST /internal/performances takes raw facts in chunks of 100 and upserts them, INSERT … ON CONFLICT(teamId, date) DO UPDATE inside db.batch(), so re-running a date overwrites and cannot duplicate.Three properties fall out of this, and all three are worth more than the autonomy the direct-write design would have bought:
model/scoring.ts is the only basePoints in the repository, and pricing.ts imports it too. The JVM and TS runtimes cannot disagree about what a day was worth, because only one of them computes it.model/enums.ts and nothing in the Kotlin module.Chunking at 100 also keeps each backend invocation inside the Worker limits tabulated above — the ingest side of the pipeline is many small invocations, each of which parses a small JSON chunk and awaits I/O, which is exactly the shape a Worker is good at.
Pageviews and links are fetched per article, so the Wikimedia rate limits, not Worker limits, are the operative constraint. They are comfortably met because the daily volume is small and the throughput is paced; a compliant User-Agent carrying contact information is mandatory and lifts the floor:
| Client | Limit | Time to fetch ~6k articles | Source |
|---|---|---|---|
| Unauthenticated, bare IP | 10 req/min | ~10 h ✗ | [3] |
| UA-compliant | 200 req/min | ~30 min ✓ | [3] |
| Authenticated (token) | 2,000 req/min | ~3 min ✓ | [3] |
| Concurrency (recommended) | ≤ 3 concurrent | , | [3] |
→ Run as a UA-compliant client at ≤3 concurrent; ~6k requests is 3–30 minutes of a batch that has all night. Friends-scale leagues are far smaller. Links follow the same budget.
Authentication is headroom, not a requirement: a UA-compliant unauthenticated client already gets 200 req/min, enough for ~6k articles. Authenticating raises the ceiling to 2,000 req/min and gives Wikimedia a contactable identity.
Special:OAuthConsumerRegistration/propose/oauth2 on Meta-Wiki for a client ID and secret [9].Authorization: Bearer <token> and the contact-info User-Agent on every request [9].Cloud Run Jobs with Cloud Scheduler, which this ADR originally chose. Both are free at this cadence and run containers to completion with no cluster to operate. It lost to GitHub Actions on a single constraint: Actions is already where this repository's automation lives, so it adds no Google Cloud account, no IAM surface, and no second place to look when a nightly run fails. The choice is also no longer expensive to revisit — the collector is a container image, and SCORING_RUNNER=external hands the schedule over without a code change. The one remaining obstacle is specific to Cloud Run: it pulls only from Artifact Registry or GCR, so targeting it needs the GHCR image mirrored. Anything that can pull a public OCI image — a VPS, Fly.io, a scheduler on a machine you own — needs no mirror at all.
Direct D1 writes over the REST API, with the collector holding a database token. Rejected for the invariant above: it would have made the collector a second writer of rules it does not contain. Its one advantage was independence from the backend's uptime, and that advantage is smaller than it looks — the collector reads its inputs from the backend regardless, so a backend that is down stops the run either way.
Running the batch on a paid Workers plan. Rejected on the limits table: the memory ceiling does not move, subrequests are raised rather than removed, and it would add no second runtime.
model/scoring.ts from both sides.docs/domain/fantawiki-requirements.md is reconciled to match.The workflow graph and what a green build is allowed to mean, then the suites that decide it and the tiers they are split into.
Ten workflows, one entry point. ci-cd.yml fires on every push and pull request and fans out to the rest; everything else is either called by it or runs on a schedule of its own.
Rendering diagram…
check.yml is the gate and the only one. It runs format, lint, typecheck, audit and every test suite across both Node packages and the Kotlin module. Everything downstream, deploys, releases, images, waits for it, so nothing ships from a revision that did not pass. Its aggregate, ci-cd / success, is the one status the master ruleset requires.
The backend suite is a matrix. One leg runs it against D1, the other against MongoDB, on separate runners and with fail-fast off: a failure on one store only is a different finding from a failure on both, and cancelling one leg because the other failed would hide exactly that comparison. The format, lint, typecheck and audit gates belong to the code, not to a store, so the D1 leg runs them and the Mongo leg runs its suite alone. The coverage report comes from the D1 leg only, so two legs never race to upload one artefact. → Persistence Targets
Every pull request runs check.yml, a fork's included. The workflow names no secret, and on a pull_request event GitHub hands a fork none, so there is nothing to refuse. What does need a secret is either gated on a deploying branch (deploy, release, images, docs) or skipped for a fork (the Codecov upload, which has no token to upload with).
A pull request from a branch of this repository is also built on push, and both runs are kept. On push, the dispatcher compares against the previous push; on a pull request, against the base branch, which is the whole diff and so the run that decides a merge. A filter that compared push to push alone could skip a suite that failed one push earlier.
On master and dev, nothing: both deploy, and a filtered run there would publish a coverage board with holes in it. Elsewhere, the dispatcher asks the compare API which paths a change touched and runs only the jobs those paths can have broken. A change to model/ or dto/ counts as both Node packages, and a change to the build or to CI itself runs everything. Anything the dispatcher cannot answer counts as touched: a filter that wrongly skips ships a break behind a green check, one that wrongly runs costs two minutes.
docs.yml is the one job with a filter of its own: it publishes only from master, and only when the push touched something the site is built from. → About this site: what that job actually does
| Check | Fails when |
|---|---|
format | Prettier or ktlint would rewrite a file |
lint | ESLint has any warning at all in the backend (--max-warnings 0); detekt finds an issue in the collector |
typecheck | tsc --noEmit disagrees, including the separate test tsconfig; the Kotlin compiler warns (allWarningsAsErrors) |
test | Any suite in any package fails, on either persistence target |
audit | A production dependency has an advisory above the configured threshold |
Coverage is measured on every run and reported, not gated. Codecov records the backend's line coverage from every run that tests the backend, and the coverage board draws all three suites; neither is a status the ruleset reads, and codecov.yml marks Codecov's own status informational so it cannot become one by accident. A floor would be a number to steer by, and the number does not say what it is usually taken to say (Test strategy).
A push to master that reached production is handed to release.yml, which computes the next version from the Conventional Commits since the last one, tags it and publishes a GitHub release with the notes. The collector image is tagged with the same version on its way to GHCR, so an image and the notes that describe it share a name. A push whose commits are all chore, ci, test, build, refactor or style deploys and releases nothing. → Release Process
Conventional Commits, enforced by a commit-msg git hook the Gradle build installs. The commit type is also the branch prefix, feat/, fix/, refactor/, so a branch name says what kind of change it carries, and it is the input the release is computed from. → Development Process
npm scripts are camelCase with no separators, formatfix, not format:fix. Gradle's node plugin reads : as subproject notation and _ as a space, so the naming is a build constraint rather than a preference. → NPM Script Naming
Renovate opens dependency updates on renovate/* branches, which are checked, merged once green, and never deployed on their own.
Two things run without anyone asking.
Nightly scoring, ~05:00 UTC, roughly two hours after Wikimedia publishes the previous UTC day, a buffer wide enough to absorb GitHub's cron jitter. It runs the collector image from GHCR against production only, keyed for concurrency on the date so two runs never score the same day at once.
Contract settlement, 07:00 UTC, a Cloudflare Cron Trigger inside the Worker, which starts a durable Workflow. It is not a GitHub job: it has to survive interruption and resume, which is what Workflows are for. The two-hour gap after scoring is deliberate and neither job may be moved alone, a contract expiring today is still scorable for yesterday, and settling it first would cost that team its last day (Contract Settlement).
The backend's testing rule is unusual and worth stating first, because everything else follows from it: what separates the tiers is not how much of the stack they exercise; it is which layer they are allowed to name.
That rule exists to protect one property. The persistence target must be replaceable, so a second implementation of the repository interfaces should be able to run this suite unchanged and have it mean the same thing. A test that reaches for a query because it is convenient has quietly made that impossible.
This is no longer a promise the suite makes about a hypothetical target. There are two, D1 and MongoDB, and ./gradlew check runs the same files against both.
Rendering diagram…
Every backend test runs in the Workers pool against a real database, reset before each test, every collection emptied and the baseline re-seeded on MongoDB, the schema dropped and the migrations replayed on D1. There is no in-memory substitute and no mocked query builder: the thing under test talks to the thing that ships.
The two runs are the same files. npm test runs them against D1 and npm run testmongo against MongoDB, and the seam between them is a single test module, tests/support/target.ts, which reads the same PERSISTENCE binding production reads, through the same composition root. The suite therefore cannot be pointed at a combination a deployment could not also be. Locally ./gradlew check runs the two one after the other; in CI they are the two legs of a matrix, on separate runners, each reporting on its own (Continuous delivery).
The Mongo run starts a single-node replica set of its own, because the guarded writes are multi-document transactions and a standalone server refuses them. Starting it rather than requiring one is what lets ./gradlew check run both targets on any machine, with nothing installed and no service to remember to start.
| Where | May name | For |
|---|---|---|
tests/**/*.spec.ts | interfaces, fakes | Unit tests. No database at all. |
tests/integration/ | services, Repositories | The rules a caller sees |
tests/routes/ | routers, Repositories | Statuses, payload shapes, and what never leaves |
tests/repositories/conformance/ | Repositories, nothing below | The gate a second implementation must pass |
tests/repositories/d1/ | *RepositoryD1, SQL, env.db | Facts that are true of D1 alone |
*.password.test.ts | credentials(), src/indexPassword.ts | Username/password sign-in, which only one build has |
tests/support/ | Repositories (a target only under support/<target>/) | The seam and the fixtures |
The conformance tier is the interesting one. It is written against the interfaces and nothing below them, which makes it a portable specification: point tests/support/target.ts at another implementation and the same file becomes that implementation's acceptance criteria.
The rule is enforced, not trusted. no-restricted-imports forbids anything under services/, routes/ or tests/ from importing repositories/d1/** or repositories/mongo/**, with the directories whose purpose is naming a target exempted by name. Lint catches the erosion that code review eventually stops catching.
Two tiers are collected by one run and not the other, and for different reasons. The target-specific tier is about a store: an inlined literal in a view, a NOT NULL that provokes a rollback, an empty IN (), none of them questions to put to a document database, so the Mongo run skips them. The password tier is about a build: only the MongoDB entry module mounts username/password sign-in, and the deployed Worker does not contain that code at all, so only the Mongo run has anything to run.
→ Backend Testing: the canonical rules · Auth Modes: why one build has a route the other does not
Through the interfaces, never with a query, and never with defaulted fixture values. tests/support/subjects.ts holds the helpers, aPlayer(), aTeamIn(leagueId), aLeague(league, foundingTeamName), and the last one takes a whole NewLeague so that a test says everything a production caller says.
The reason to ban defaults is specific: a fixture that quietly fills in a field is a test that passes for a reason the test does not state, and it keeps passing after the field starts mattering.
Both were found the hard way and are worth knowing before writing a backend test:
vi.mock silently does nothing. A test that appears to stub a module is testing the real one. Substitute through the constructor instead, which is why services take their dependencies that way.WorkflowEntrypoint cannot be constructed in a test. The settlement logic therefore lives in a service the Workflow calls, not in the entrypoint, and the service is what the tests drive.Frontend specs are regression smoke. They cover the stores, the services and a handful of views, and they exist to catch a refactor that breaks rendering, not to re-assert the game's rules. Those are tested where they are implemented.
Duplicating a rule into the browser suite would mean two places to change every time the rule moves, and the second one would be found late, by a red build nobody expected.
Two specifics that repeatedly cost time:
beforeEnter by mounting the page gives the guard a different Pinia instance and quietly tests nothing.MSW backs every test with onUnhandledRequest: "error", so a request no handler expects fails the test rather than escaping to the network.
→ Frontend Testing: what every mount already has, how to stub one response, and where a test goes
There is no browser-driven end-to-end suite, and that is a decision rather than an omission. It is worth being exact about what it leaves untested, because most of what an end-to-end suite would check is already checked closer to where it can break:
| Seam | Checked by |
|---|---|
| A rule, against a real database | The backend suite, on both persistence targets |
| A route's statuses and payloads, over HTTP, in the Workers runtime | The routes tier |
| The backend's routes against what the frontend is told they are | openapi.spec.ts, in both directions |
| A screen, against those payloads | The frontend suite, through MSW |
| The collector, against Wikimedia's and the backend's responses | The Kotlin suite, through a mock HTTP engine |
What none of them covers is the real browser talking to the real Worker over the real network: a cookie a browser refuses, a proxy that drops a header, a page that renders against a live response nobody mocked. That seam was tested by the only thing that can test it completely, which is people playing on production. The playtest ran for 35 days with thirteen players, and what it found is recorded there.
The trade-off is stated plainly: a regression in that seam is found by a player, or by an author on the dev preview environment, rather than by CI. An end-to-end suite would move that discovery earlier at the price of the slowest and flakiest tier there is, run against a Worker and a database that would have to be stood up per run. For a game of friends' leagues that ships from one commit to one environment, that price was judged not worth it.
4,043 of 5,545 executable lines are exercised by the test suites. Codecov gates the project at 70% overall and does not gate the changed lines of a pull request on their own.
Measured 21 September 2026 at 15:13 UTC, on the revision that built this page.
Routes are excluded from the report: they are covered end to end by the integration tier, which drives them through real HTTP.
The lowest of the three, and expected to be. Frontend specs are regression smoke around stores, services and views; the game rules they would otherwise re-assert are tested where they live, in the backend. Mocks, the bootstrap and the specs themselves are excluded.
The nightly batch. Kover counts the whole module, including the Wikimedia dump parsing that carries most of its risk.
Measured on master at 3680074, 2026-09-18, from the CI run's own reports:
| Suite | Lines | Branches |
|---|---|---|
| Backend (D1 leg) | 64.8% | 65.0% |
| Frontend | 76.2% | 67.4% |
| Scoring collector | 88.3% | 56.7% |
The backend figure understates the backend. Of its 651 uncovered lines, 370 are the MongoDB repositories and 157 are test helpers that only the Mongo leg runs, and the Mongo leg is not instrumented: coverage comes from the D1 leg alone. Leave those two out and about 90% of the lines on the D1 path execute. Instrumenting the Mongo leg and merging the two reports is the change that would make the number mean what it looks like it means.
The collector's branches are the weakest figure here, and they are in one place. 64 of its 84 uncovered branches are in WikimediaClient.kt, the client for the one upstream the project does not control, and 22 of its 26 uncovered lines are Main.kt, the entry point, which no test runs. The first is where the next collector test belongs.
The board reports line coverage for all three suites. Two things about it are worth stating, because both are routinely assumed the other way.
A covered line is a line some test caused to execute. It is not a line whose behaviour anyone asserted. A suite can push the figure up by importing modules and never checking their output; what stops that here is the seeding rule above and the habit of reviewing for it, not the percentage.
The three figures are not comparable with one another. The backend excludes its route modules from the report on purpose: routes are exercised end to end by the integration tier, which drives them over real HTTP, and counting them twice would flatter the number. The frontend's figure should be read with the first point above in mind: its specs are regression smoke, and mounting a view executes code that no assertion checks. The Kotlin collector counts everything it has, including the Wikimedia response parsing that carries most of its risk.
./gradlew check # everything: format, lint, test, audit, both targets
cd backend && npm test # the Workers-pool suite, against D1
cd backend && npm run testmongo # the same files, against MongoDB
cd backend && npm run test-coverage
cd frontend && npm test
cd frontend && npm run hot-test
npx vitest run src/tests/auth/LoginPage.spec.ts # one file./gradlew check is what CI runs, and it is the definition of "green". Anything that passes locally but not there is a difference in the environment, not in the tests.
Which branch reaches which environment and in what order, the target that is shipped as a container image rather than deployed, and the whole stack orchestrated locally from one file.
Everything is branch-driven. There is no deploy button and no manual promotion: what is on master is production, what is on dev is QA, and a feature branch is checked and nothing else.
Rendering diagram…
The two environments are genuinely separate down to the database: QA has its own D1 instance, its own Google OAuth client and its own JWT secret. A bad migration on dev cannot reach a real player's league.
→ Deploy Strategy · Dev Branch Deployment
Rendering diagram…
Migrations first, then the Worker, then the frontend. The order is not negotiable: a Worker deployed against a schema it expects but D1 has not received yet is a production error for as long as the gap lasts.
Rendering diagram…
The Worker bundles model/ and dto/ from the repository root, so the deploy installs the root dependencies before running Wrangler, esbuild resolves those imports from the root node_modules, not the backend's.
The backend runs on either of two stores, and this page describes only one of them. No Cloudflare deployment runs on MongoDB: production and preview are D1, and the MongoDB target runs locally, against a replica set.
Keeping it that way took more than intent. composition.ts names both targets, so the driver is reachable from the Worker's entry point, and making the import dynamic does not help, esbuild follows dynamic imports and inlines them. The dry run measured it: 1.5MB of driver shipped with a Worker that can never use it, and the build failed without nodejs_compat, because the driver imports net, tls and child_process.
So wrangler.jsonc aliases the driver to a stub, which brings the production bundle back to within 8 KiB gzipped of its pre-MongoDB size and leaves the D1 deployments configured exactly as they were. The alias is a top-level key with no per-environment form, which is why the MongoDB run has a wrangler config of its own rather than a fifth environment, and a configuration that can never be deployed does not belong in the file that deploys.
./gradlew devMongo # the frontend, and the Worker on MongoDBThat config also names a different entry module, which is what gives the MongoDB run username/password sign-in and the deployed Worker none of it. → Persistence Targets · Auth Modes
Scoring is the one scheduled job that lives outside Cloudflare, and it runs the published container image rather than the sources.
publish-images.yml pushed to GHCR for master, so the night scores exactly the artefact that was published, no checkout, no JDK.SCORING_RUNNER=external, hands the nightly over to something else without a code change. A manual dispatch always runs regardless , clicking Run is an explicit instruction, and it is how a missed day gets backfilled mid-handover.| Secret | Held by | What it opens |
|---|---|---|
CLOUDFLARE_API_TOKEN · CLOUDFLARE_ACCOUNT_ID | The deploy job | Wrangler and Pages |
GOOGLE_CLIENT_ID · GOOGLE_CLIENT_SECRET | The Worker, per environment | Sign-in |
JWT_SECRET | The Worker, per environment | Session signing |
SCORING_INGEST_SECRET | The Worker and the nightly job | /internal/* |
GH_APP_PRIVATE_KEY | The Worker | Filing problem reports as the bot |
CLOUDFLARE_D1_MIGRATION_RUNNER_SECRET | The migration job | Replaying migrations |
GITHUB_TOKEN | Actions, automatically | GHCR, and publishing this site |
The GitHub App key is optional on purpose: a deploy still succeeds before it is set, and the report form answers 502 until it is. A feature that is not configured yet should degrade, not block a release.
The collector holds exactly two credentials, the ingest secret and a Wikimedia user agent, and no database access at all. It is the least privileged thing in the system despite being the one that runs unattended.
./gradlew noGenie brings up the frontend, the Worker and a local D1 with no credentials to obtain: the compose file wires the dev sign-in route, which mints a normal session and refuses to exist outside the local environment. Three sibling tasks, up, demo, demoNoGenie, add the Article Genie, the seeded demo league, or neither.
→ Running FantasyWiki in Docker · Local Development Setup
For someone who wants to run FantasyWiki without installing Node or npm, and, the harder half, without being handed any of the project's credentials.
git clone https://github.com/FantasyWiki/FantasyWiki.git
cd FantasyWiki
./gradlew noGenieThen open http://localhost:5173 and sign in with Continue as demo player.
There is nothing else to obtain: no Cloudflare account, no Google OAuth client secret, no env file to fill in. That gives you an empty database to build in, and the Article Genie switched off, which is what noGenie names.
Two things about the stack are optional, and they switch independently, so there are four combinations. Naming each one is the whole point: the alternative is remembering which -f files and --profile flags compose into which run, and getting it wrong at the worst moment.
| Genie off | Genie on | |
|---|---|---|
| Empty database | ./gradlew noGenie | ./gradlew up |
| Seeded database | ./gradlew demoNoGenie | ./gradlew demo |
local-genie environment, which binds Workers AI; off means local, which binds no model. See The Article Genie.Each task is a wrapper thin enough to read: it sets one environment variable and runs docker compose up. ./gradlew tasks --group docker prints all four, and the raw form still works if you prefer it,
NPM_CMD=dev docker compose --profile demo up # == ./gradlew demoNoGenieNPM_CMD is the Genie axis (which npm script the backend container runs, defaulting to devgenie) and --profile demo is the data axis. Gradle sets NPM_CMD through the process environment rather than the command line, so the tasks behave the same under bash, PowerShell and WSL.
Two features would normally each stop a fresh clone dead, and each is switched off rather than faked.
| Feature | Needs | Without it |
|---|---|---|
| Article Genie | A Cloudflare account (Workers AI) | noGenie runs the local environment, which binds no model, so the backend starts and the market shows no Genie at all, see Article Genie LLM Integration |
| Google sign-in | The project's OAuth client secret | GET /auth/dev mints the same session without it |
/auth/dev It is the Google flow with the identity provider removed: the same LoginService call, the same claims, the same JWT_SECRET, the same session_token cookie, the same redirect to /auth/callback. The demo player is an ordinary player, so nothing downstream knows this route exists.
It is gated twice, and the two gates do not trust each other:
ENVIRONMENT is "local". Only the local and local-genie environments in wrangler.jsonc carry that value; production, preview and test do not. 404 rather than 403 because outside local development the route should not appear to exist at all.VITE_DEV_LOGIN=true.JWT_SECRET is still required, but it is self-generated randomness, not a shared credential: the container entrypoint writes a backend/.dev.vars from the committed .dev.vars.example with a fresh random value when the file is missing.
Compose is a development convenience, and it cannot become a deployment target: the frontend is a Cloudflare Pages project and the backend a Worker, both serverless. Nothing in production runs a container of this app, so no compose.yaml here is ever up anywhere but a laptop. The one image the project does ship, the Kotlin scoring collector, is not part of this stack; it is below.
The image is deliberately narrow in two places, for different reasons. It carries Node but no JDK, the bind mount puts gradlew right there in /workspace with nothing behind it, and the only Cloudflare credential it is ever handed is the Genie's API token, which is narrower than the deploy credential CI holds but is not read-only, see the note on Workers Scripts · Edit in Creating a Cloudflare API token. noGenie hands it none at all, which is what keeps a fresh clone runnable.
| In a container | Natively | |
|---|---|---|
| Run it, click around, sign in | ✅ | ✅ |
| Edit code with hot reload | ✅ | ✅ |
npm test / lint / format, per subproject | ✅ | ✅ |
wrangler dev, local D1 migrations, cf-typegen | ✅ | ✅ |
| The Article Genie | ✅ with an API token | ✅ with wrangler login |
./gradlew check --parallel, the PR gate | ❌ no JDK | ✅ |
wrangler deploy, db:migrate:remote | ❌ no Pages or D1 credential | ✅ |
So: containers to run FantasyWiki, the native toolchain to ship it. Anyone opening a PR needs the second as well, which is what Local Development Setup installs, and that path needs no Cloudflare account either, only a JDK.
up and demo run the Worker on wrangler's local-genie environment, which binds Workers AI. Workers AI has no local simulator, so every call is proxied to the real model on Cloudflare's edge and Wrangler has to authenticate. That is the whole reason this needs credentials and noGenie does not.
wrangler login Natively, wrangler login is the usual answer. It cannot work here: its OAuth callback listens on localhost:8976, and inside a container that is the container's localhost, the browser doing the consenting is on the host, and nothing routes back. The flow would sit on a URL nobody can complete.
So use an API token, which Wrangler reads straight from the environment and prefers over any stored OAuth session. No browser, no login step.
Creating the token is the same job whether you run in Docker or not, so it is written once, next to the other credentials: Creating a Cloudflare API token. The short version is the dashboard's https://dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token, with Workers Scripts · Edit (needed for the remote preview session, and the one everybody misses), Workers AI · Read and Account Settings · Read.
What is specific to Compose is only where the two values go:
cp backend/.dev.vars.example backend/.dev.vars # if you have none yetFill the two CLOUDFLARE_* values in at the bottom of that file. There is no .env in the repository root and deliberately so: this project's local secrets already have a home, and Compose passes the whole of backend/.dev.vars into the container's environment (env_file), which is where Wrangler looks for them. That file is gitignored and dockerignored, so it reaches neither a commit nor an image layer.
One Compose detail worth knowing if you ever add a secret with a `
in it: the env_file entry carries format: raw, without which Compose expands `
inside the values it reads and the secret arrives silently truncated.
Then ./gradlew up: the backend prints ==> Article Genie on and the market grows its Genie button.
Without a token, up and demo stop before Wrangler starts, with a message naming this section, a clear failure rather than a hang on an OAuth flow that cannot finish. noGenie and demoNoGenie are unaffected: Compose passes both variables always, so an undefined one arrives as the empty string, and the entrypoint unsets it. An empty token is worse than none, Wrangler would see a credential, try it, and fail authentication instead of running credential-free.
Nothing else in the stack ever sees these variables. The token is not read-only, though, the remote preview session forces Workers Scripts · Edit, so it can publish a Worker, if not a Pages build or a D1 migration.
Not here. All four tasks bind-mount the repository and serve the frontend from the Vite dev server, so an edit on the host restarts Wrangler and hot-reloads Vite. They publish the same ports, so nothing about the app's URLs changes between them.
There was once a second compose file that baked the sources in and served the built bundle through vite preview. It was deleted, because the thing it was for is done better elsewhere: every push to dev runs npm run build and wrangler pages deploy (.github/workflows/deploy-target.yml), so the real production bundle is already live at https://dev.fantasywiki.pages.dev, against the real Worker and the real database, and at a URL you can send someone, which a container on your laptop is not.
The backend never had a second mode to lose. wrangler dev is the only way to run a Worker locally, so it runs the same command either way; only the frontend had a dev/built split at all.
The two answers differ deliberately.
demo and demoNoGenie arrive populated. You get a public league, Wikipedia Premier, with three rival teams, full 4-3-3 squads and four scored days each, so the market shows owned articles, the standings rank somebody, and the podium has a reason to appear. A second command standing between a visitor and a working app is one too many.
The demo Compose profile does this by adding a db-seed service that runs to completion before the backend starts. It is the same image and the same entrypoint, so it applies the migrations first and seeds a schema that exists, and because the backend waits on it the two never hold the D1 files at once.
up and noGenie start empty, which is the honest state for someone about to found their own league. To fill one anyway, without restarting:
docker compose exec backend npm run db:seed:demoBoth paths run the same backend/seeds/demo.sql, deliberately not a migration, because no deployed database should ever see it. It deletes its own rows before reinserting them, so re-running it replaces the demo league rather than stacking copies, and its timestamps are relative to now, so the season never ages out from under the data. That is what makes seeding-on-every-boot safe rather than merely first-run.
The database lives in a named volume and survives docker compose down. To start clean:
docker compose down -vDo not remap the ports. http://127.0.0.1:8787/auth/google is registered with Google as a redirect URI, and FRONTEND_URL feeds both that redirect and the session cookie's Secure flag. 8787 and 5173 are effectively part of the configuration.
The installs are the container's, not yours. Each node_modules sits in a named volume mounted over the bind mount. The backend's better-sqlite3 is a native module, and a copy compiled on Windows or macOS cannot load inside a Linux container. A mysterious invalid ELF header means one of those mounts is missing.
127.0.0.1 means "this container". Vite's dev proxy therefore reads BACKEND_ORIGIN (http://backend:8787 under Compose) rather than hardcoding the loopback address it uses when both processes run on one machine. This is the single likeliest thing to get wrong.
Everything binds 0.0.0.0. Wrangler and Vite both bind loopback by default, which is unreachable through a published port, hence --ip 0.0.0.0 and VITE_HOST.
File watching polls. Bind-mounted filesystems do not deliver inotify events on Windows or macOS, so CHOKIDAR_USEPOLLING and VITE_POLL are set. It costs some CPU; without it, edits are simply never noticed.
Compose environment beats .env.local. Vite lets a real environment variable win over the file, so the container behaves the same whatever a developer happens to have in their own frontend/.env.local.
wrangler login does nothing useful in a container. Its OAuth callback listens on localhost:8976, which inside a container is the container's own loopback; the browser consenting is on the host and nothing routes back. Use a CLOUDFLARE_API_TOKEN in .env, The Article Genie.
Sign in with Google fails in a container unless GOOGLE_CLIENT_SECRET is filled into backend/.dev.vars, the backend answers a raw 500. Use the demo button, or supply the secret and both routes work.
.github/workflows/publish-images.yml pushes to GHCR on master and dev, behind the same gate as the Cloudflare deploys: an image is only worth publishing for a revision that passed check. GITHUB_TOKEN is the whole credential, no new secret, unlike the deploys next door.
Today it publishes the one service Cloudflare does not host:
| Image | Tags |
|---|---|
ghcr.io/fantasywiki/scoring-collector | sha-<short> always, plus latest on master and dev on dev |
sha-<short> is the tag a rollback names; the branch aliases are what a human or a compose file pulls.
Its Dockerfile is runtime-only, the Gradle distribution is built before the image, not inside it. A Gradle stage would have to carry the whole monorepo, because settings.gradle.kts configures the Node subprojects and installs git hooks, all for a build the runner has already done with a warm cache. To build it by hand:
./gradlew :scoring-collector:installDist
docker build -f docker/scoring-collector.Dockerfile -t scoring-collector .Adding the backend and frontend demo images is one more entry in that workflow's matrix, the day someone wants docker compose pull instead of a local build.
Publishing an image rather than building from source at run time buys two things: the nightly scores exactly the artefact that passed check for master, and the nightly does not have to run on a GitHub runner at all. Any host that can pull the image and set three environment variables can own the day instead. Both routes, the command each takes, and the repository variable that hands over between them are in Nightly Scoring Pipeline.
Which AI tools were used to build the project, at what level of involvement in each process, how agents were kept inside the project's rules, and which skills they used and why.
Generative AI was used to build FantasyWiki, and this page says for what, how, and under which constraints. The machine-readable form of the same statement is AI-DECLARATION.md, which follows the AI-DECLARATION.md convention: when the two disagree, fix whichever is wrong, they are one claim.
It is about how the code was written. The Article Genie also uses a language model, but as a feature that runs in production, and it is covered where the feature is: ADR 0006.
Two tools: Claude Code, in the terminal and the editor, and GitHub Copilot, both as a coding agent that opens pull requests from copilot/* branches and in the editor.
| Process | Level | What that meant here |
|---|---|---|
| Design | pair | The domain model, the economy and every ADR were decided by the authors, with an agent as the other side of the argument |
| Implementation | copilot | An author states the task; the agent carries it out, asking before it acts; the author reviews the diff |
| Testing | copilot | As implementation, mostly test-first |
| Documentation | copilot | As implementation, against the voice and rules in docs/agents/documentation-site.md |
| Deployment | copilot | Workflows and build scripts, as implementation |
| Review | assist | An agent points at problems; the decision to merge is an author's |
The levels are the convention's: pair is both acting on the task with the human understanding its internals; copilot is the agent doing the whole task while asking for permission or clarification; assist is the agent acting on a part of it. Nothing here is auto, an agent completing a task with no human in the loop.
One instruction file. AGENTS.md is what every agent reads, Claude Code through CLAUDE.md, which imports it, and Copilot directly. There used to be three copies, one per tool, and the Copilot one had fallen behind the build it described. A second copy of an instruction is a second instruction.
The vocabulary and the decisions are written down first. CONTEXT.md is the glossary an agent must use, and docs/adr/ holds the decisions it must not relitigate. Both are human-owned: an agent may propose a term or an ADR, and an author writes it. This is what keeps a model's fluent synonym for Free Agent out of the code.
The tests are the check, not the agent's confidence. Backend tests run against a real database on both persistence targets, and a rule an agent implemented is accepted when a test states it, not when the agent says it is done (Backend Testing).
Only an author merges. master accepts nothing but pull requests with signed commits and a green ci-cd / success (Development Process). An agent's work, whether a Copilot pull request or a Claude Code session's diff, reaches master when an author merges it, and in the Claude Code case after the author has committed it themselves.
Where to see it. Commits written with Claude Code carry a Co-Authored-By: Claude trailer when the session added one; 36 commits on master do, the first on 2026-06-21. The six commits the Copilot agent authored carry Copilot as their author. The absence of a trailer is not evidence that no agent was involved.
A skill is a packaged set of instructions an agent loads for one kind of task. Four were used, each for a job where an unguided agent fails in a recognisable way.
| Skill | Used for | Why a skill rather than a prompt |
|---|---|---|
grill-me | Stress-testing a design before it became an ADR | An agent asked to review a plan tends to agree with it. This one interviews the author branch by branch until every open decision is resolved, and it is the author who answers. |
tdd | Red, green, refactor on backend rules | It makes the agent write the failing test first and stop there, so the test states the rule before any code can shape it to fit. |
diagnose | Hard bugs | It forces reproduce, minimise, hypothesise, instrument, before any fix, instead of the plausible patch an agent reaches for first. |
improve-codebase-architecture | Finding modules worth deepening or merging | It reads CONTEXT.md and docs/adr/ before proposing anything, so its suggestions use the project's words and respect decisions already taken. |
The skills read their project configuration from fixed paths under docs/agents/, which is why that directory must not move. It also holds the configuration for an issue-tracker and a triage skill; those were set up with the others and not used, which is why no issue carries a triage label.
What was built, which decisions held, and what would be done differently.
The two halves came apart. The architecture delivered what it promised: built, run on production for a month, cost nothing, nothing bought again to carry the load. The game it was built to serve does not work, for a reason no further building would fix.
The playtest is where that was found. This page is what follows from it.
| ADR | Verdict |
|---|---|
| 0001 Base Scoring Model | Held, and unbalanced. The curve behaved, but its additive chemistry term outgrew the pageview scoring it was meant to garnish. Players played for an all-Excellent board, not for points. |
| 0002 Language Scale Factor | No verdict. One edition, all month. It solves a problem the run never presented. |
| 0003 Closed Trading Economy | Challenged. The least-liked part of the game. Renewal went effectively unused (three uses against two hundred contracts) and Early Sell was spent during setup rather than played. Players asked for a stock market instead. |
| 0004 Scoring Engine Platform | Held, with the scheduler and the delivery path revised during implementation. Free, and both failures were the project's own code rather than the platform. |
| 0005 Contract Pricing | Held. The curve priced a 1,000-credit budget into real choices across two hundred contracts, from free articles up to 625 credits, and a team that spent badly kept a low-credit route back into scoring. |
| 0006 Article Genie | Vindicated, against expectation. Arrived mid-run to open scepticism and was received warmly, as a humorous and occasionally useful extra. The only feature that landed better than it was pitched. |
| 0007 Derived Team Credits | Held, and checked rather than assumed. Across 13 teams and 200 contracts: no negative balances, and no contract settled twice. Final balances ran 302 to 1,570 against a 1,000 budget. |
| 0008 League Invitation Codes | Untested. Nobody joined a league by code. |
Two are marked no verdict or untested, and that is itself the finding. A month of play tested the decisions about scoring, money and infrastructure, and left the decisions about leagues almost entirely alone, because the run was one league everyone was already in. The parts of the system that get a player from nothing to a league with their friends in it are the least evidenced parts of the project.
Four, in descending order of how much they matter. Only the first is about the game.
Decided against. Sourcing trends from Google search rather than Wikipedia. Several players thought it would make a better game and they may be right, but the analytics API costs hundreds of dollars a month, which ends it for a project whose whole cost argument is that it runs on free tiers. Wikipedia's openness is not incidental here; it is what makes the project possible at all.
Proposed by players. Two ideas came out of the run, offered as ways the game could be better for them rather than as requests for a backlog:
| Idea | Note |
|---|---|
| An auction for contracts, instead of a fixed price against the market | Rejected at the original pitch as tedious, then asked for after a month of play |
| Head-to-head competition: fixtures scored one against one, three points for a win in an all-play-all, then the top of the table into a knockout | A leaderboard turned out to be a ranking, not a rivalry |
Neither is committed to. Both are recorded because they came from people who had played for a month, which makes them better evidence than an idea from inside the project. Neither answers the question below, so building either now would answer the wrong thing.
Deferred earlier, still deferred: weekly and monthly tournaments, demand-reactive pricing, and a global season. That last is a season format open to everyone, and is not the seeded league named Global League the playtest ran in.
The premise still looks right. Wikipedia's daily trends are good raw material for a game, and nothing in the run argued against it. What the run took away is the shape: a fantasy-sport format cannot carry it, because there is nothing for it to be a fantasy of.
Planning stopped there rather than picking one of the proposals above. What comes next is game design, not another sprint, and it has not been done yet.