Skip to main content

ADR-0001 — One repository, pnpm workspaces

Date2026-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/shared consumed 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/shared has no build step. Its main points at src/index.ts. Both consumers import TypeScript source directly. There is no dist, no publish, no version to skew.
  • node-linker=hoisted in .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.jswatchFolders so editing a shared schema triggers a reload, and nodeModulesPaths so it finds the root store.
  • Documentation content lives at the repo root in docs/, not inside apps/docs. The site is configured with path: '../../docs'. Docs are a first-class part of the repo; apps/docs is 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=hoisted means 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.ts declares the Web Crypto surface by hand instead of pulling in lib.dom — DOM types would let browser-only APIs typecheck and then fail on device.

What this creates:

  • The docs-check workflow, which is only possible because docs and code share a diff.
  • Extensionless imports within packages/shared, because Metro will not resolve ./foo.js to foo.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/shared generate the mobile SQLite schema too? Today the local schema in apps/mobile/src/lib/db.ts is 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?