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.js — watchFolders 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?