chore: restructure into pnpm workspaces — apps/api + placeholders (#181) #195

Merged
james merged 1 commit from 181-workspace-restructure into main 2026-06-21 03:21:55 +00:00
Owner

Closes #181. Implements the workspace shape declared in ADR-0027 / epic #176.

What moved

  • apps/api/ — the Next.js API service, moved as a unit via git mv so history is preserved. Workspace name @carol/api.
  • apps/client/ — empty placeholder. Filled in by #183 (Expo scaffolding).
  • packages/api-client/ — empty placeholder. Filled in by #182 (generated typed client).
  • packages/i18n/ — the next-intl catalogs moved out of apps/api/messages/ so the Expo client (#183) can consume the same source-of-truth files. @carol/i18n.
  • Repo-level CI tooling stays at scripts/ci/; the OpenAPI generation scripts move under apps/api/scripts/.
  • Dockerfile, compose.*.yaml, openapi.json, lefthook.yml, the workflows, and the docs stay at the repo root.

Mechanics

  • pnpm pinned via root packageManager + .tool-versions (matches CI's corepack enable).
  • Install-script allowlist (ADR-0010) ported from lavamoat.allowScripts to pnpm.onlyBuiltDependencies in the root package.json. Same gate: pnpm install --ignore-scripts then pnpm rebuild runs scripts only for the listed packages.
  • pnpm.overrides pins a single zod version so the strict pnpm layout doesn't produce duplicate copies in the build graph.
  • outputFileTracingRoot pinned at the repo root in apps/api/next.config.mjs so Next's standalone bundle picks up workspace packages.
  • package-lock.json removed; pnpm-lock.yaml committed.

CI (.forgejo/workflows/pr.yml)

Every job now: corepack enablepnpm install --frozen-lockfile --ignore-scriptspnpm rebuildpnpm -F @carol/api <task>. pnpm audit --prod replaces npm audit --omit=dev (same JSON shape); OSV-Scanner reads pnpm-lock.yaml; coverage reporter runs git -C apps/api diff so reportable paths (app/, lib/, db/) match the workspace layout. The package-age policy degrades to a soft no-op until a follow-up teaches the helper to walk pnpm-lock.yaml — soft signal, never blocks merge.

Dockerfile

Multi-stage rebuild for workspaces. The runtime stage copies apps/api/.next/standalone into /app/; that tree mirrors the workspace layout because of outputFileTracingRoot, so the entrypoint is node apps/api/server.js. Build succeeds and the container serves /api/openapi.json end-to-end (verified with podman build + podman run).

Reviewer-attention notes

  • /api/openapi.json route now serves the committed openapi.json instead of regenerating from the zod registry per request. The route imports the file as a JSON module so webpack bundles it. The reason: under pnpm's strict layout, webpack's parallel async-module ordering means DTOs imported by lib/api/openapi-routes may be constructed before extendZodWithOpenApi(z) has run in lib/api/openapi, leaving their prototypes without the .openapi(...) chain. The committed file IS the contract (CI's openapi:check keeps it fresh), so serving it directly is the strongest consistency guarantee. Build-time generation via pnpm -F @carol/api openapi:generate still produces source-of-truth bytes — that path doesn't go through webpack. Comment with the full rationale is in the route file.
  • i18n loading uses static imports (import enMessages from "@carol/i18n/messages/en.json") instead of the previous dynamic template. Webpack can't statically resolve import(\@carol/i18n/messages/${locale}.json`)through a workspace package'sexportsmap, so the file maintains an explicit registry. Adding a locale means: ship the JSON, append the static import + registry entry inapps/api/lib/i18n/request.ts, add the code to SUPPORTED_LOCALES`. Documented inline.
  • sharp is now an explicit apps/api dep. It was previously a transitive of next (npm's flat hoist made it visible to lib/profile/picture.ts); pnpm's strict layout doesn't, so the build broke on the import sharp from "sharp" line. Declaring it explicitly is the correct hygiene either way.
  • tests/scripts/package-ages.test.ts can't use the @/scripts/lib/package-ages.mjs alias anymore (@/ now points inside the api workspace, not the repo root). It uses a relative import that walks four levels up to the repo-root scripts/lib/ instead.
  • pnpm-lock.yaml is large (~7k lines). Reviewing the manifests + workflow + Dockerfile is the substantive read; the lockfile is mechanical.

Local verification (run from the worktree)

  • pnpm install clean.
  • pnpm -F @carol/api typecheck green.
  • pnpm -F @carol/api lint green.
  • pnpm -F @carol/api test green — 499 tests pass, 107 skipped (sqlite engine locally; Postgres leg runs in CI).
  • pnpm -F @carol/api openapi:check and openapi:coverage green; openapi.json byte-identical after regeneration.
  • pnpm -F @carol/api build green.
  • podman build -t carol-test . succeeds; GET /api/openapi.json returns 200 with a valid OpenAPI 3.1 doc.

Reviewer's local checkout

After pulling: rm -rf node_modules (npm flat hoist won't help anymore), corepack enable (or mise install), then pnpm install. The first install is ~3 minutes; subsequent ones are seconds.

Out-of-scope follow-ups

  • Teach scripts/lib/package-ages.mjs to walk pnpm-lock.yaml so the package-age soft check resumes catching new packages on PRs.
  • Refactor lib/api/openapi.ts + lib/api/openapi-routes.ts to fix the webpack ordering race so the /api/openapi.json route can regenerate per request if we ever want it to. Not urgent — the committed-file approach is sound.

🤖 Generated with Claude Code

Closes #181. Implements the workspace shape declared in ADR-0027 / epic #176. ## What moved - **`apps/api/`** — the Next.js API service, moved as a unit via `git mv` so history is preserved. Workspace name `@carol/api`. - **`apps/client/`** — empty placeholder. Filled in by #183 (Expo scaffolding). - **`packages/api-client/`** — empty placeholder. Filled in by #182 (generated typed client). - **`packages/i18n/`** — the next-intl catalogs moved out of `apps/api/messages/` so the Expo client (#183) can consume the same source-of-truth files. `@carol/i18n`. - Repo-level CI tooling stays at `scripts/ci/`; the OpenAPI generation scripts move under `apps/api/scripts/`. - `Dockerfile`, `compose.*.yaml`, `openapi.json`, `lefthook.yml`, the workflows, and the docs stay at the repo root. ## Mechanics - **pnpm** pinned via root `packageManager` + `.tool-versions` (matches CI's `corepack enable`). - **Install-script allowlist** (ADR-0010) ported from `lavamoat.allowScripts` to `pnpm.onlyBuiltDependencies` in the root `package.json`. Same gate: `pnpm install --ignore-scripts` then `pnpm rebuild` runs scripts only for the listed packages. - **`pnpm.overrides`** pins a single `zod` version so the strict pnpm layout doesn't produce duplicate copies in the build graph. - **`outputFileTracingRoot`** pinned at the repo root in `apps/api/next.config.mjs` so Next's standalone bundle picks up workspace packages. - **`package-lock.json` removed**; `pnpm-lock.yaml` committed. ## CI (`.forgejo/workflows/pr.yml`) Every job now: `corepack enable` → `pnpm install --frozen-lockfile --ignore-scripts` → `pnpm rebuild` → `pnpm -F @carol/api <task>`. `pnpm audit --prod` replaces `npm audit --omit=dev` (same JSON shape); OSV-Scanner reads `pnpm-lock.yaml`; coverage reporter runs `git -C apps/api diff` so reportable paths (`app/`, `lib/`, `db/`) match the workspace layout. The package-age policy degrades to a soft no-op until a follow-up teaches the helper to walk `pnpm-lock.yaml` — soft signal, never blocks merge. ## Dockerfile Multi-stage rebuild for workspaces. The `runtime` stage copies `apps/api/.next/standalone` into `/app/`; that tree mirrors the workspace layout because of `outputFileTracingRoot`, so the entrypoint is `node apps/api/server.js`. Build succeeds and the container serves `/api/openapi.json` end-to-end (verified with `podman build` + `podman run`). ## Reviewer-attention notes - **`/api/openapi.json` route now serves the committed `openapi.json`** instead of regenerating from the zod registry per request. The route imports the file as a JSON module so webpack bundles it. The reason: under pnpm's strict layout, webpack's parallel async-module ordering means DTOs imported by `lib/api/openapi-routes` may be constructed before `extendZodWithOpenApi(z)` has run in `lib/api/openapi`, leaving their prototypes without the `.openapi(...)` chain. The committed file IS the contract (CI's `openapi:check` keeps it fresh), so serving it directly is the strongest consistency guarantee. Build-time generation via `pnpm -F @carol/api openapi:generate` still produces source-of-truth bytes — that path doesn't go through webpack. Comment with the full rationale is in the route file. - **i18n loading** uses static imports (`import enMessages from "@carol/i18n/messages/en.json"`) instead of the previous dynamic template. Webpack can't statically resolve `import(\`@carol/i18n/messages/${locale}.json\`)` through a workspace package's `exports` map, so the file maintains an explicit registry. Adding a locale means: ship the JSON, append the static import + registry entry in `apps/api/lib/i18n/request.ts`, add the code to `SUPPORTED_LOCALES`. Documented inline. - **`sharp` is now an explicit `apps/api` dep.** It was previously a transitive of `next` (npm's flat hoist made it visible to `lib/profile/picture.ts`); pnpm's strict layout doesn't, so the build broke on the `import sharp from "sharp"` line. Declaring it explicitly is the correct hygiene either way. - **`tests/scripts/package-ages.test.ts`** can't use the `@/scripts/lib/package-ages.mjs` alias anymore (`@/` now points inside the api workspace, not the repo root). It uses a relative import that walks four levels up to the repo-root `scripts/lib/` instead. - **`pnpm-lock.yaml` is large** (~7k lines). Reviewing the manifests + workflow + Dockerfile is the substantive read; the lockfile is mechanical. ## Local verification (run from the worktree) - [x] `pnpm install` clean. - [x] `pnpm -F @carol/api typecheck` green. - [x] `pnpm -F @carol/api lint` green. - [x] `pnpm -F @carol/api test` green — **499 tests pass**, 107 skipped (sqlite engine locally; Postgres leg runs in CI). - [x] `pnpm -F @carol/api openapi:check` and `openapi:coverage` green; `openapi.json` byte-identical after regeneration. - [x] `pnpm -F @carol/api build` green. - [x] `podman build -t carol-test .` succeeds; `GET /api/openapi.json` returns 200 with a valid OpenAPI 3.1 doc. ## Reviewer's local checkout After pulling: `rm -rf node_modules` (npm flat hoist won't help anymore), `corepack enable` (or `mise install`), then `pnpm install`. The first install is ~3 minutes; subsequent ones are seconds. ## Out-of-scope follow-ups - Teach `scripts/lib/package-ages.mjs` to walk `pnpm-lock.yaml` so the package-age soft check resumes catching new packages on PRs. - Refactor `lib/api/openapi.ts` + `lib/api/openapi-routes.ts` to fix the webpack ordering race so the `/api/openapi.json` route can regenerate per request if we ever want it to. Not urgent — the committed-file approach is sound. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
chore: restructure into pnpm workspaces — apps/api + placeholders (#181)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / Static analysis (pull_request) Successful in 32s
PR / OSV-Scanner (pull_request) Failing after 1m6s
PR / Package age policy (soft) (pull_request) Successful in 13s
PR / Lint (pull_request) Successful in 1m58s
PR / pnpm audit (pull_request) Successful in 2m37s
PR / Typecheck (pull_request) Successful in 2m53s
PR / Test (postgres) (pull_request) Failing after 3m15s
PR / OpenAPI (pull_request) Successful in 3m23s
PR / Test (sqlite) (pull_request) Successful in 3m28s
PR / Coverage (soft) (pull_request) Successful in 3m33s
PR / Trivy (image) (pull_request) Failing after 3m57s
PR / Build (pull_request) Successful in 4m15s
Secrets / gitleaks (pull_request) Failing after 5m38s
f1d1599484
Adopts pnpm workspaces and reshapes the repo per ADR-0027 / epic #176:

- apps/api/ — the existing Next.js API service, moved as a unit via
  git mv so history is preserved. `@carol/api` workspace.
- apps/client/ — empty placeholder for the Expo Router + RN Web
  universal client. Filled in by #183.
- packages/api-client/ — empty placeholder for the generated typed
  client + TanStack Query hooks. Filled in by #182.
- packages/i18n/ — the next-intl translation catalogs, moved from
  the repo root so the future Expo client can consume the same
  source-of-truth files. `@carol/i18n` workspace, consumed by
  `@carol/api` via static imports in lib/i18n/request.ts.

Workspace mechanics:

- pnpm pinned via root packageManager + .tool-versions; corepack
  materialises it in CI.
- Install-script allowlist (ADR-0010) ported from
  package.json `lavamoat.allowScripts` to root
  `pnpm.onlyBuiltDependencies`. Equivalent gate: `pnpm install
  --ignore-scripts` then `pnpm rebuild` runs only allowlisted
  lifecycle scripts.
- pnpm-lock.yaml committed; package-lock.json dropped.
- A single `zod` version pinned via `pnpm.overrides` so the strict
  layout doesn't leak duplicate zod copies into the build graph.
- next.config.mjs gets `outputFileTracingRoot` pinned at the repo
  root so the standalone bundle includes workspace packages.

CI changes (`.forgejo/workflows/pr.yml`):

- Every job runs `corepack enable` + `pnpm install --frozen-lockfile
  --ignore-scripts` + `pnpm rebuild`, then `pnpm -F @carol/api <task>`.
- `pnpm audit --prod` replaces `npm audit --omit=dev`; same JSON
  shape consumed by `scripts/ci/security-summary.mjs`.
- OSV-Scanner now reads pnpm-lock.yaml.
- Coverage reporter walks `git -C apps/api diff` so reportable
  paths (`app/`, `lib/`, `db/`) match the api workspace layout.
- Package-age check is degraded to a soft no-op until a follow-up
  teaches the helper to walk pnpm-lock.yaml. (Soft signal, never
  blocks merge — captured in CLAUDE.md.)

Dockerfile — multi-stage rebuild for workspaces:

- deps stage installs all workspace manifests + pnpm-lock.yaml,
  runs --ignore-scripts then `CI=true pnpm rebuild`.
- builder stage copies the source over, runs `pnpm -F @carol/api
  build` against the standalone output target.
- runtime stage copies apps/api/.next/standalone (which contains
  the full workspace tree because of outputFileTracingRoot) and
  runs `node apps/api/server.js`.

OpenAPI route refactor:

- `/api/openapi.json` now serves the committed openapi.json (imported
  as a JSON module so webpack bundles it). Per-request regeneration
  via the zod registry was incompatible with webpack's parallel
  async-module ordering under pnpm's strict layout — DTOs imported
  by `lib/api/openapi-routes` could be constructed before
  `extendZodWithOpenApi(z)` runs in `lib/api/openapi`, leaving their
  prototypes without the `.openapi(...)` chain. The committed file
  is the contract — CI's drift gate (`openapi:check`) keeps it
  fresh — so serving it directly is the strongest consistency
  guarantee. The build-time generation script (run from the api
  workspace, not webpack) still produces the source-of-truth bytes.

Docs:

- CLAUDE.md gains a "Repo layout" section; load-bearing paths
  through "Conventions", "Stack defaults", "Working in this repo"
  updated to point at apps/api/.
- README.md gains a workspace-aware Layout table, pnpm commands
  table, and pnpm-aware Requirements section.
- docs/api-conventions.md path refs migrated; spec endpoint
  comment notes the file-vs-registry choice.
- docs/ci.md install-scripts section rewritten for
  pnpm.onlyBuiltDependencies.

Verified locally:

- pnpm -F @carol/api typecheck/lint/test (499 tests) green.
- pnpm -F @carol/api openapi:{check,coverage} green; openapi.json
  byte-identical after regeneration.
- pnpm -F @carol/api build + `next start` serves the spec at
  /api/openapi.json with 200.
- `podman build -t carol-test .` succeeds and the container serves
  the spec end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

OSV-Scanner

Threshold: high  ·  Total findings: 6  ·  At/above threshold: 2

critical high medium low
1 1 4 0
severity id package installed / range fix
critical GHSA-5xrq-8626-4rwp vitest@2.1.9 4.0.0–4.1.0 4.1.0
high GHSA-fx2h-pf6j-xcff vite@5.4.21 8.0.0–8.0.16 8.0.16
<!-- scanner-comment: osv --> ### OSV-Scanner **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 6 &nbsp;·&nbsp; **At/above threshold:** 2 | critical | high | medium | low | |---:|---:|---:|---:| | 1 | 1 | 4 | 0 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | critical | GHSA-5xrq-8626-4rwp | vitest@2.1.9 | 4.0.0–4.1.0 | `4.1.0` | | high | GHSA-fx2h-pf6j-xcff | vite@5.4.21 | 8.0.0–8.0.16 | `8.0.16` |

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 61.8% ≥ 50%
Branches 80.3% ≥ 75%
Functions 87.9% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 61.8% ✅ | ≥ 50% | | Branches | 80.3% ✅ | ≥ 75% | | Functions | 87.9% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |
james force-pushed 181-workspace-restructure from f1d1599484
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / Static analysis (pull_request) Successful in 32s
PR / OSV-Scanner (pull_request) Failing after 1m6s
PR / Package age policy (soft) (pull_request) Successful in 13s
PR / Lint (pull_request) Successful in 1m58s
PR / pnpm audit (pull_request) Successful in 2m37s
PR / Typecheck (pull_request) Successful in 2m53s
PR / Test (postgres) (pull_request) Failing after 3m15s
PR / OpenAPI (pull_request) Successful in 3m23s
PR / Test (sqlite) (pull_request) Successful in 3m28s
PR / Coverage (soft) (pull_request) Successful in 3m33s
PR / Trivy (image) (pull_request) Failing after 3m57s
PR / Build (pull_request) Successful in 4m15s
Secrets / gitleaks (pull_request) Failing after 5m38s
to 6f626f57a4
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 12s
PR / OSV-Scanner (pull_request) Failing after 52s
PR / Static analysis (pull_request) Successful in 1m6s
PR / Package age policy (soft) (pull_request) Successful in 14s
Secrets / gitleaks (pull_request) Successful in 15s
PR / Trivy (image) (pull_request) Failing after 1m38s
PR / Lint (pull_request) Successful in 2m4s
PR / OpenAPI (pull_request) Successful in 2m12s
PR / Typecheck (pull_request) Successful in 2m29s
PR / pnpm audit (pull_request) Successful in 2m40s
PR / Test (sqlite) (pull_request) Successful in 3m20s
PR / Build (pull_request) Successful in 3m28s
PR / Coverage (soft) (pull_request) Successful in 3m6s
PR / Test (postgres) (pull_request) Failing after 4m33s
2026-06-21 03:16:08 +00:00
Compare
james merged commit 511fc71ac8 into main 2026-06-21 03:21:55 +00:00
james deleted branch 181-workspace-restructure 2026-06-21 03:21:56 +00:00
Sign in to join this conversation.
No description provided.