ADR-0001 — One repository, pnpm workspaces
| Date | 2026-08-17 |
| Supersedes | — |
| Superseded by | — |
Context
Crag Topo has four things that ship: an iOS app, an HTTP API, a documentation site, and the type contract between the app and the API.
That contract is the crux. The app and the API must agree on the shape of a Route, an Ascent,
and every sync message. If they disagree, the failure is a runtime crash on a phone at a crag —
the worst place to find out.
There is also a hard requirement that every change updates the documentation in the same pull request. That is only enforceable if the documentation is in the same repository as the code.
Decision
One repository, pnpm workspaces, with
@crag-topo/sharedconsumed as TypeScript source.
apps/api Cloudflare Worker (Hono) + D1
apps/mobile Expo / React Native
apps/docs Docusaurus renderer
packages/shared Zod schemas — the contract
docs/ The MDX documentation itself
Specifics:
packages/sharedhas no build step. Itsmainpoints atsrc/index.ts. Both consumers import TypeScript source directly. There is nodist, no publish, no version to skew.node-linker=hoistedin.npmrc. pnpm's symlinked layout is better in every way except the one that matters: Metro cannot resolve it reliably. This is the configuration Expo supports.- Metro is told about the workspace in
apps/mobile/metro.config.js—watchFoldersso editing a shared schema triggers a reload, andnodeModulesPathsso it finds the root store. - Documentation content lives at the repo root in
docs/, not insideapps/docs. The site is configured withpath: '../../docs'. Docs are a first-class part of the repo;apps/docsis only the renderer.
Consequences
What gets harder:
- CI installs everything to test anything. Roughly 1,800 packages, ~25s warm. Acceptable now; worth revisiting with a task runner if it reaches minutes.
node-linker=hoistedmeans a package can import something it does not declare and get away with it. Undeclared-dependency bugs surface later than they should.- Everyone gets an Expo toolchain even if they only touch the Worker.
What we are committed to:
- One version of every shared dependency across the workspace. Two packages wanting different major versions of Zod is a real problem, not a shrug.
- Shared code must stay runnable in three runtimes: workerd, Hermes, and Node. That is why
packages/shared/src/globals.d.tsdeclares the Web Crypto surface by hand instead of pulling inlib.dom— DOM types would let browser-only APIs typecheck and then fail on device.
What this creates:
- The
docs-checkworkflow, which is only possible because docs and code share a diff. - Extensionless imports within
packages/shared, because Metro will not resolve./foo.jstofoo.ts.
Reversing it: splitting into separate repositories means publishing @crag-topo/shared to a
registry and accepting version skew between the app and the API. Perfectly doable, and the
day-one cost of getting it wrong is much lower than the ongoing cost of skew, which is why we are
not starting there.
Alternatives considered
Separate repositories with a published shared package
The conventional split. Rejected because the shared contract is the thing most likely to break, and a published package puts a version boundary exactly where we want a compile error. It also makes "docs change in the same PR" unenforceable.
Monorepo with a build step for packages/shared
Compile to dist, consume the artefact. Rejected as pure overhead: Metro and Wrangler both bundle
TypeScript already. A build step buys us a stale-dist failure mode we would otherwise not have.
Turborepo or Nx
Caching and task orchestration. Not rejected — deferred. With four packages, pnpm -r is enough,
and adding an orchestrator now is complexity ahead of the problem. Revisit when CI is slow enough
to notice.
npm or yarn workspaces
Both work. pnpm chosen for install speed and stricter default resolution — though hoisted gives
up much of the strictness. If the Metro constraint ever lifts, that strictness is worth
reclaiming.
Open questions
- Should
packages/sharedgenerate the mobile SQLite schema too? Today the local schema inapps/mobile/src/lib/db.tsis maintained by hand alongside the D1 one, and they can drift. That is the sharpest edge this decision leaves. - At what CI duration does a task runner earn its place?