Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/admin/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ COPY libs/tailwind-animation libs/tailwind-animation
COPY libs/zod-utils libs/zod-utils
COPY apps/admin apps/admin

# Synchronise injected workspace packages after copying source code.
# See: docs/decisions/002-workspace-package-injection.md
RUN pnpm install --frozen-lockfile --offline

# Build libs and app.
RUN pnpm --filter admin... build

Expand Down Expand Up @@ -99,4 +103,8 @@ COPY libs/prisma/migrations libs/prisma/migrations
COPY libs/prisma/prisma.config.ts libs/prisma/prisma.config.ts
COPY libs/prisma/schema.prisma libs/prisma/schema.prisma

# Synchronise injected workspace packages after copying build artifacts.
# See: docs/decisions/002-workspace-package-injection.md
RUN pnpm install --prod --frozen-lockfile --offline

CMD ["pnpm", "--filter", "admin", "start"]
10 changes: 10 additions & 0 deletions apps/show/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ COPY libs/core/package.json libs/core/
COPY libs/dev-tools/package.json libs/dev-tools/
COPY libs/file-storage/package.json libs/file-storage/
COPY libs/files-io/package.json libs/files-io/
COPY libs/password/package.json libs/password/
COPY libs/prisma/package.json libs/prisma/
COPY libs/react-primitives/package.json libs/react-primitives/
COPY libs/search-params-io/package.json libs/search-params-io/
Expand All @@ -55,12 +56,17 @@ COPY libs/core libs/core
COPY libs/dev-tools libs/dev-tools
COPY libs/file-storage libs/file-storage
COPY libs/files-io libs/files-io
COPY libs/password libs/password
COPY libs/prisma libs/prisma
COPY libs/react-primitives libs/react-primitives
COPY libs/search-params-io libs/search-params-io
COPY libs/zod-utils libs/zod-utils
COPY apps/show apps/show

# Synchronise injected workspace packages after copying source code.
# See: docs/decisions/002-workspace-package-injection.md
RUN pnpm install --frozen-lockfile --offline

# Build libs and app.
RUN pnpm --filter show... build

Expand Down Expand Up @@ -95,4 +101,8 @@ COPY libs/prisma/migrations libs/prisma/migrations
COPY libs/prisma/prisma.config.ts libs/prisma/prisma.config.ts
COPY libs/prisma/schema.prisma libs/prisma/schema.prisma

# Synchronise injected workspace packages after copying build artifacts.
# See: docs/decisions/002-workspace-package-injection.md
RUN pnpm install --prod --frozen-lockfile --offline

CMD ["pnpm", "--filter", "show", "start"]
10 changes: 10 additions & 0 deletions apps/website/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ FROM base AS packages-json
COPY package.json ./
COPY libs/core/package.json libs/core/
COPY libs/dev-tools/package.json libs/dev-tools/
COPY libs/password/package.json libs/password/
COPY libs/prisma/package.json libs/prisma/
COPY libs/search-params-io/package.json libs/search-params-io/
COPY libs/zod-utils/package.json libs/zod-utils/
Expand All @@ -50,11 +51,16 @@ RUN pnpm install --frozen-lockfile --offline

COPY libs/core libs/core
COPY libs/dev-tools libs/dev-tools
COPY libs/password libs/password
COPY libs/prisma libs/prisma
COPY libs/search-params-io libs/search-params-io
COPY libs/zod-utils libs/zod-utils
COPY apps/website apps/website

# Synchronise injected workspace packages after copying source code.
# See: docs/decisions/002-workspace-package-injection.md
RUN pnpm install --frozen-lockfile --offline

# Build libs and app.
RUN pnpm --filter website... build

Expand Down Expand Up @@ -89,4 +95,8 @@ COPY libs/prisma/migrations libs/prisma/migrations
COPY libs/prisma/prisma.config.ts libs/prisma/prisma.config.ts
COPY libs/prisma/schema.prisma libs/prisma/schema.prisma

# Synchronise injected workspace packages after copying build artifacts.
# See: docs/decisions/002-workspace-package-injection.md
RUN pnpm install --prod --frozen-lockfile --offline

CMD ["pnpm", "--filter", "website", "start"]
2 changes: 1 addition & 1 deletion docs/decisions/001-file-extensions.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File Extensions in Imports
# File extensions in imports

Date: **2026-03-22**

Expand Down
181 changes: 181 additions & 0 deletions docs/decisions/002-workspace-package-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Force workspace package injection

Date: **2026-04-03**

Status: **Accepted**

## Context

We need to upgrade some dependencies (e.g. React, Remix / React Router, and
Tailwind CSS) but we can't update them in the entire workspace at once. The plan
is to upgrade them app by app. This means different apps can temporarily require
different versions of the same dependencies, and libs should work with different
versions of their peer dependencies.

To support this, we already moved to [pnpm][pnpm], because it has the features
needed to manage app-specific dependency versions in a monorepo.

By default, workspace packages are symlinked to consumers' `node_modules/`
folder and this works fine when they all share the same versions of
dependencies. But once we start staggered upgrades, this setup becomes risky.

For example, even when `app-2` depends on React 18, `lib-1` may resolve React 19
because `lib-1` is symlinked to its workspace location:

```
.
├─ apps
│ ├─ app-1
│ │ ├─ package.json
│ │ │ └─ deps: lib-1, react@19
│ │ └─ node_modules/
│ │ ├─ lib-1/ -> ../../libs/lib-1/
│ │ └─ react/ -> ../../../node_modules/.pnpm/react@19/node_modules/react/
│ ├─ app-2/
│ │ ├─ package.json
│ │ │ └─ deps: lib-1, react@18
│ │ └─ node_modules/
│ │ ├─ lib-1/ -> ../../libs/lib-1/
│ │ └─ react/ -> ../../../node_modules/.pnpm/react@18/node_modules/react/
├─ libs/
│ └─ lib-1/
│ ├─ package.json
│ │ └─ peer deps: react@18|19
│ └─ node_modules/
│ └─ react/ -> ../../../node_modules/.pnpm/react@19/node_modules/react/
└─ node_modules/
└─ .pnpm/
├─ react@18/
└─ react@19/
```

Because module resolution starts from the real location of `lib-1` (in `libs/`),
the peer dependency seen by the library can differ from the one installed in the
consumer app.

## Decision

We will use pnpm's workspace package injection feature to install workspace
libraries in the virtual store, treating them like regular dependencies. This
means a copy of each lib is created in the virtual store for each distinct peer
dependency context.

This allows each lib to resolve its peer dependencies as defined by the app.

For example, `lib-1` uses React 19 when imported in `app-1`, and React 18 when
imported in `app-2`.

```
.
├─ apps
│ ├─ app-1
│ │ ├─ package.json
│ │ │ └─ deps: lib-1, react@19
│ │ └─ node_modules/
│ │ ├─ lib-1/ -> ../../../node_modules/.pnpm/lib-1@_react@19/node_modules/lib-1/
│ │ └─ react/ -> ../../../node_modules/.pnpm/react@19/node_modules/react/
│ ├─ app-2/
│ │ ├─ package.json
│ │ │ └─ deps: lib-1, react@18
│ │ └─ node_modules/
│ │ ├─ lib-1/ -> ../../../node_modules/.pnpm/lib-1@_react@18/node_modules/lib-1/
│ │ └─ react/ -> ../../../node_modules/.pnpm/react@18/node_modules/react/
├─ libs/
│ └─ lib-1/
│ └─ package.json
│ └─ peer deps: react@18|19
└─ node_modules/
└─ .pnpm/
├─ lib-1@_react@18/
│ └─ node_modules/
│ ├─ lib-1/
│ └─ react/ -> ../../react@18/node_modules/react/
├─ lib-1@_react@19/
│ └─ node_modules/
│ ├─ lib-1/
│ └─ react/ -> ../../react@19/node_modules/react/
├─ react@18/
└─ react@19/
```

### How to inject packages

We'll set [`injectWorkspacePackages`][inject-workspace-packages] to `true` in
`pnpm-workspace.yaml` so workspace packages are installed as injected copies in
the virtual store instead of simple symlinks.

Also, we'll set [`dedupeInjectedDeps`][dedupe-injected-deps] to `false`. This
prevents pnpm from collapsing multiple injected copies back into a single
symlink when peer dependencies are compatible across different apps, which would
defeat the purpose of isolation.

In each `package.json`, set the `files` field to list which files and folders
pnpm should synchronise into injected copies. This tells pnpm exactly which
contents from the source directory should be included when creating a copy.
Without the `files` field, pnpm will not include the `build/` folder in injected
copies, which means consumers will use stale or missing built artifacts.

### How to synchronise injected packages

The [`syncInjectedDepsAfterScripts`][sync-injected-deps-after-scripts] setting
in `pnpm-workspace.yaml` ensures pnpm automatically synchronises an injected
package with its source each time specified scripts finish. One-off scripts like
`build` are straightforward to track, but `dev` scripts are more complex: they
run once and keep tools in watch mode, so subsequent rebuilds by those tools are
not detected by pnpm and the synchronisation does not happen automatically.

To solve this, we'll add a `dev:sync-workspace` script that watches the `build/`
folder and invokes a `sync-workspace` script each time the folder changes. The
`sync-workspace` script itself is a no-op, its only purpose is to trigger pnpm's
synchronisation mechanism. For consistency, configure
`syncInjectedDepsAfterScripts` to track only `sync-workspace`, and use
post-scripts (such as `postbuild`) for one-off builds, allowing each package to
control when synchronisation occurs.

In app `Dockerfile`s, the virtual store and dependencies are copied before the
source code or `build/` folders to optimize layer caching. However, pnpm can
only synchronise packages when running configured scripts. Libraries without a
build script will not be synchronised during the build, and when the final image
is constructed with only the `build/` folders copied, injected packages will
have stale contents. To ensure injected packages stay synchronised with
workspace sources, we'll add extra `pnpm install` calls at the points where
source code or build outputs change.

**Advantages**:

- 👍 Enables app-by-app dependency upgrades.
- 👍 Reduces cross-app peer dependency interference.
- 👍 Improves reproducibility between local, CI, and Docker installs.

**Drawbacks**:

- 👎 Slightly larger install footprint compared to pure symlinks.
- 👎 Slightly more complex synchronisation configuration.

## Consequences

With workspace package injection enabled, we can safely upgrade dependencies app
by app without coordinating a single workspace-wide migration. Each app's
dependency graph is isolated in the virtual store, so different apps can use
different versions of the same peer dependencies without conflicts.

The setup requires careful configuration: settings in `pnpm-workspace.yaml`, the
`files` field in each `package.json`, and explicit `pnpm install` calls in
`Dockerfile`s to ensure injected packages stay synchronised. Development scripts
must preserve synchronisation between the source and injected copies, which is
handled by the `sync-workspace` hook pattern described above.

The positive consequence is that we can now upgrade dependencies incrementally,
reducing risk and allowing teams to work independently on different apps.

## Considered options

1. **Upgrade all apps and libraries in one global dependency migration**
- 👍 Avoids temporary mixed peer dependency states.
- 👎 High coordination cost and larger blast radius.

[dedupe-injected-deps]: https://pnpm.io/workspaces#dedupeinjecteddeps
[inject-workspace-packages]: https://pnpm.io/workspaces#injectworkspacepackages
[pnpm]: https://pnpm.io/
[sync-injected-deps-after-scripts]:
https://pnpm.io/workspaces#syncinjecteddepsafterscripts
9 changes: 8 additions & 1 deletion libs/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"private": true,
"sideEffects": false,
"type": "module",
"files": [
"build"
],
"exports": {
".": {
"types": "./build/src/index.d.ts",
Expand All @@ -15,17 +18,20 @@
"build": "run-s --print-label build:src build:paths",
"build:paths": "tsc-alias -p ./tsconfig.build.json",
"build:src": "tsc -p ./tsconfig.build.json",
"postbuild": "pnpm sync-workspace",
"clean": "rm -rf ./build",
"dev": "run-p --continue-on-error --print-label dev:*",
"dev:paths": "pnpm build:paths --watch",
"dev:src": "pnpm build:src --watch",
"dev:sync-workspace": "chokidar ./build --debounce --silent -c 'pnpm sync-workspace'",
"lint": "run-p --continue-on-error --print-label lint:*",
"lint:eslint": "eslint --cache --cache-location ./node_modules/.cache/eslint --max-warnings 0 .",
"lint:prettier": "prettier --list-different .",
"lint:tsc": "tsc --noEmit",
"lint-fix": "run-s --print-label lint-fix:eslint lint-fix:prettier",
"lint-fix:eslint": "eslint --cache --cache-location ./node_modules/.cache/eslint --fix .",
"lint-fix:prettier": "prettier --write ."
"lint-fix:prettier": "prettier --write .",
"sync-workspace": "# See /docs/decisions/002-workspace-package-injection.md"
},
"dependencies": {
"@animeaux/prisma": "workspace:*",
Expand All @@ -42,6 +48,7 @@
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"chokidar-cli": "catalog:",
"eslint": "catalog:",
"npm-run-all": "catalog:",
"prettier": "catalog:",
Expand Down
8 changes: 8 additions & 0 deletions libs/dev-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
"private": true,
"sideEffects": false,
"type": "module",
"files": [
"eslint.config.js",
"prettier.config.js",
"reset.d.ts",
"tsconfig.app.json",
"tsconfig.base.json",
"tsconfig.lib.json"
],
"exports": {
"./eslint": "./eslint.config.js",
"./prettier": "./prettier.config.js",
Expand Down
9 changes: 8 additions & 1 deletion libs/file-storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"private": true,
"sideEffects": false,
"type": "module",
"files": [
"build"
],
"exports": {
".": {
"types": "./build/src/index.d.ts",
Expand All @@ -19,17 +22,20 @@
"build": "run-s --print-label build:src build:paths",
"build:paths": "tsc-alias -p ./tsconfig.build.json",
"build:src": "tsc -p ./tsconfig.build.json",
"postbuild": "pnpm sync-workspace",
"clean": "rm -rf ./build",
"dev": "run-p --continue-on-error --print-label dev:*",
"dev:paths": "pnpm build:paths --watch",
"dev:src": "pnpm build:src --watch",
"dev:sync-workspace": "chokidar ./build --debounce --silent -c 'pnpm sync-workspace'",
"lint": "run-p --continue-on-error --print-label lint:*",
"lint:eslint": "eslint --cache --cache-location ./node_modules/.cache/eslint --max-warnings 0 .",
"lint:prettier": "prettier --list-different .",
"lint:tsc": "tsc --noEmit",
"lint-fix": "run-s --print-label lint-fix:eslint lint-fix:prettier",
"lint-fix:eslint": "eslint --cache --cache-location ./node_modules/.cache/eslint --fix .",
"lint-fix:prettier": "prettier --write ."
"lint-fix:prettier": "prettier --write .",
"sync-workspace": "# See /docs/decisions/002-workspace-package-injection.md"
},
"dependencies": {
"@animeaux/core": "workspace:*",
Expand All @@ -52,6 +58,7 @@
"@animeaux/dev-tools": "workspace:*",
"@types/node": "catalog:",
"@types/uuid": "catalog:",
"chokidar-cli": "catalog:",
"eslint": "catalog:",
"npm-run-all": "catalog:",
"prettier": "catalog:",
Expand Down
Loading
Loading