Skip to content

Persistence Targets ​

The 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.

Choosing one ​

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.)

BindingD1 deploymentMongo deployment
PERSISTENCEabsentmongo
dbthe D1 databaseabsent
MONGO_URLabsentconnection string
MONGO_DBabsentoptional, 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.
  • The Vitest pool resolves modules through Vite, not wrangler's bundler, so it is unaffected and 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>.

Running the Worker on MongoDB ​

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.
  • Vite pre-bundles 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).

Running it locally ​

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:

bash
npm run devmongo         # or ./gradlew devMongo, with the frontend beside it

It 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.

One connection per request, and why it cannot be cached ​

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:

  • The test suite cannot catch this. It runs outside any request context, where the restriction does not apply, so a module-level cache passes all 571 tests and fails on the second request in wrangler dev. If you change how the connection is held, exercise it with two requests.
  • A Workflow is not one request. 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.

What is stored ​

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.

CollectionKeyed byNotes
google_accountsaccount idgoogleId unique
playersplayer idusername and accountId unique
leaguesleague idinvitationCode unique when present (partial index)
teamsteam id(playerId, leagueId) unique, one row per player per league, ever
contractscontract idindexed by teamId and by (settled, expireDate)
notificationsnotification id
performancesteamId:date(teamId, date) unique
lineupsteam ida team has at most one
language_scaleslanguage 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.

Team credits ​

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.

Guarded writes, and what replaces single-statement atomicity ​

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.

Built from the repository's own documentation. Source on GitHub.