Skip to content
Open
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
95 changes: 95 additions & 0 deletions MIGRATION-PNPM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Migration from npm to pnpm

Date: 2025-09-19

## Summary
We migrated the monorepo from `npm` to `pnpm` for improved performance, disk space efficiency via content-addressable storage, better workspace linking, and deterministic installs. This document explains the changes, how to adapt your workflow, and common command equivalents.

## Key Changes
- Added `pnpm-workspace.yaml` to define workspaces (`apps/*`, `packages/*`).
- Updated `packageManager` field in the root `package.json` to `[email protected]` enabling Corepack-managed version pinning.
- Replaced all `npm` and `npx` usages in scripts, docs, and Dockerfiles with `pnpm` / `pnpm dlx`.
- Internal workspace dependency `@database.build/deploy` now referenced using `"workspace:*"` so pnpm does not attempt to fetch it from the registry.
- Generated `pnpm-lock.yaml` and removed the legacy `package-lock.json`.
- Docker images now enable Corepack and use `pnpm dlx turbo` instead of globally installing `turbo`.

## Command Mapping
| Action | npm | pnpm |
| ------ | --- | ---- |
| Install deps | `npm install` | `pnpm install` |
| Add dep | `npm install <pkg>` | `pnpm add <pkg>` |
| Add dev dep | `npm install -D <pkg>` | `pnpm add -D <pkg>` |
| Remove dep | `npm uninstall <pkg>` | `pnpm remove <pkg>` |
| Run script | `npm run build` | `pnpm run build` or `pnpm build` |
| Execute one-off bin (was npx) | `npx <bin>` | `pnpm dlx <bin>` |
| List outdated | `npm outdated` | `pnpm outdated` |
| Update deps | `npm update` | `pnpm update` |
| Rebuild | `npm rebuild` | `pnpm rebuild` |
| Audit | `npm audit` | `pnpm audit` |

## One-off CLI (dlx)
Use `pnpm dlx` for ephemeral package execution instead of `npx`.
Examples:
```sh
pnpm dlx supabase start
pnpm dlx openapi-typescript https://api.supabase.com/api/v1-json -o ./path/to/types.ts
```

## Turbo Usage
`turbo` is invoked via `pnpm dlx turbo <command>` inside Docker to avoid global installs. Locally you can just run workspace scripts, e.g.:
```sh
pnpm dev
```
which maps to `turbo watch dev` in the root.

## Workspace Dependencies
Use the workspace protocol when referring to internal packages to ensure proper linking:
```json
"dependencies": {
"@database.build/deploy": "workspace:*"
}
```

## CI / Containers
Ensure CI images execute:
```sh
corepack enable
pnpm install --frozen-lockfile
```
If reproducibility is critical, pass `--frozen-lockfile` to error on lock drift.

## Caching Considerations
pnpm's store is content-addressable and can be cached across builds (e.g., in GitHub Actions cache the directory from `pnpm store path`). Retrieve it like:
```sh
pnpm store path
```

## Typical Developer Flow
```sh
corepack enable # first time only (often already enabled)
pnpm install # install deps
pnpm dev # start dev environment
pnpm build # build all
```

## Troubleshooting
| Symptom | Cause | Fix |
| ------- | ----- | --- |
| 404 installing a local workspace package | Missing `workspace:*` spec | Update dependency to `"workspace:*"` |
| WARN about packages installed by different manager | Leftover `node_modules` from npm | Remove root & sub `node_modules` and reinstall (`git clean -fdx` or manual delete) |
| Command not found after migration | Using `npx` still | Switch to `pnpm dlx <cmd>` |

## Cleanup Leftovers
If you still have stray `package-lock.json` files locally, remove them. Only `pnpm-lock.yaml` should be committed.

## Future Updates
To bump pnpm version (maintained by Corepack):
```sh
corepack prepare pnpm@<new-version> --activate
```
Update the `packageManager` field accordingly in the root `package.json`.

---
Migration owners: @maintainers

If anything is unclear, open an issue with the label `pnpm`.
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,20 @@ From the monorepo root:
1. Install dependencies

```shell
npm i
pnpm install
```

2. Start local Supabase stack:
```shell
npx supabase start
pnpm dlx supabase start
```
3. Store local Supabase URL/anon key in `./apps/web/.env.local`:
```shell
npx supabase status -o env \
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY |
grep NEXT_PUBLIC >> ./apps/web/.env.local
```
```shell
pnpm dlx supabase status -o env \
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY |
grep NEXT_PUBLIC >> ./apps/web/.env.local
```
4. Create an [OpenAI API key](https://platform.openai.com/api-keys) and save to `./apps/web/.env.local`:
```shell
echo 'OPENAI_API_KEY="<openai-api-key>"' >> ./apps/web/.env.local
Expand Down Expand Up @@ -76,7 +76,7 @@ From the monorepo root:
From the monorepo root:

```shell
npm run dev
pnpm dev
```

_**Important:** This command uses `turbo` under the hood which understands the relationship between dependencies in the monorepo and automatically builds them accordingly (ie. `./packages/*`). If you by-pass `turbo`, you will have to manually build each `./packages/*` before each `./app/*` can use them._
Expand Down
4 changes: 3 additions & 1 deletion apps/browser-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
FROM node:22-alpine

RUN corepack enable

WORKDIR /app

COPY --link package.json ./
COPY --link src/ ./src/

RUN npm install
RUN pnpm install --frozen-lockfile || pnpm install

EXPOSE 443
EXPOSE 5432
Expand Down
12 changes: 7 additions & 5 deletions apps/deploy-worker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
FROM node:22-alpine AS base

# Enable corepack and prepare pnpm
RUN corepack enable

FROM base AS builder

WORKDIR /app

RUN npm install -g turbo@^2
COPY . .

# Generate a partial monorepo with a pruned lockfile for a target workspace.
RUN turbo prune @database.build/deploy-worker --docker
# Generate a partial monorepo with a pruned lockfile for a target workspace using turbo via pnpm dlx
RUN pnpm dlx turbo prune @database.build/deploy-worker --docker

FROM base AS installer
WORKDIR /app

# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN npm install
RUN pnpm install --frozen-lockfile || pnpm install

# Build the project
COPY --from=builder /app/out/full/ .
RUN npx turbo run build [email protected]/deploy-worker
RUN pnpm dlx turbo run build [email protected]/deploy-worker

FROM base AS runner

Expand Down
8 changes: 4 additions & 4 deletions apps/deploy-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
"type": "module",
"scripts": {
"start": "node --env-file=.env --experimental-strip-types src/index.ts",
"dev": "npm run start",
"dev": "pnpm run start",
"build": "echo 'built'",
"type-check": "tsc",
"generate:database-types": "npx supabase gen types --lang=typescript --local > ./supabase/database-types.ts",
"generate:management-api-types": "npx openapi-typescript https://api.supabase.com/api/v1-json -o ./supabase/management-api/types.ts"
"generate:database-types": "pnpm dlx supabase gen types --lang=typescript --local > ./supabase/database-types.ts",
"generate:management-api-types": "pnpm dlx openapi-typescript https://api.supabase.com/api/v1-json -o ./supabase/management-api/types.ts"
},
"dependencies": {
"@database.build/deploy": "*",
"@database.build/deploy": "workspace:*",
"@hono/node-server": "^1.13.2",
"@hono/zod-validator": "^0.4.1",
"@supabase/supabase-js": "^2.45.4",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/app/api/integrations/[id]/details/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,21 @@ const supabasePlatformConfig: SupabasePlatformConfig = {
* management API. Details include the organization ID and name
* that the integration is scoped to.
*/
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = createClient()
const supabaseAdmin = createAdminClient()

const ctx = {
supabase,
supabase: supabaseAdmin,
supabaseAdmin,
supabasePlatformConfig,
}

const integrationId = parseInt(id, 10)

try {
const { data: integration, error: getIntegrationError } = await supabase
const { data: integration, error: getIntegrationError } = await supabaseAdmin
.from('deployment_provider_integrations')
.select('*, provider:deployment_providers!inner(id, name)')
.eq('id', integrationId)
Expand Down
72 changes: 50 additions & 22 deletions apps/web/app/api/oauth/supabase/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,46 @@ import { NextRequest, NextResponse } from 'next/server'
import { createClient as createAdminClient } from '~/utils/supabase/admin'
import { createClient } from '~/utils/supabase/server'

type Credentials = {
// Represents the stored credentials secret shape
interface Credentials {
refreshToken: string
accessToken: string
expiresAt: string
}

// Tokens returned from the Supabase OAuth token endpoint
interface SupabaseOAuthTokens {
access_token: string
refresh_token: string
expires_in: number // seconds until expiry
token_type: 'Bearer'
}

// Organization object returned from platform API
interface SupabaseOrganization {
id: string
name: string
}

// State passed through the OAuth flow (extend as needed)
interface OAuthState {
databaseId: string
[key: string]: unknown
}

// Minimal shapes for Supabase table rows we touch (until generated types are wired in)
interface DeploymentProvider {
id: string
}

interface DeploymentProviderIntegration {
id: string
scope: { organizationId: string }
credentials: string
revoked_at: string | null
deployment_provider_id: string
}

/**
* This route is used to handle the callback from Supabase OAuth App integration.
* It will exchange the oauth code for tokens and create or update a deployment integration against the given provider.
Expand Down Expand Up @@ -36,7 +70,7 @@ export async function GET(req: NextRequest) {
return new Response('No state provided', { status: 400 })
}

const state = JSON.parse(stateParam)
const state: OAuthState = JSON.parse(stateParam)

if (!state.databaseId) {
return new Response('No database id provided', { status: 400 })
Expand Down Expand Up @@ -66,13 +100,7 @@ export async function GET(req: NextRequest) {
return new Response('Failed to get tokens', { status: 500 })
}

const tokens = (await tokensResponse.json()) as {
access_token: string
refresh_token: string
// usually 86400 seconds = 1 day
expires_in: number
token_type: 'Bearer'
}
const tokens: SupabaseOAuthTokens = await tokensResponse.json()

const organizationsResponse = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_PLATFORM_API_URL}/v1/organizations`,
Expand All @@ -89,10 +117,7 @@ export async function GET(req: NextRequest) {
return new Response('Failed to get organizations', { status: 500 })
}

const [organization] = (await organizationsResponse.json()) as {
id: string
name: string
}[]
const [organization]: SupabaseOrganization[] = await organizationsResponse.json()

if (!organization) {
return new Response('Organization not found', { status: 404 })
Expand All @@ -111,18 +136,21 @@ export async function GET(req: NextRequest) {

// check if an existing revoked integration exists with the same organization id
const getRevokedIntegrationsResponse = await supabase
.from('deployment_provider_integrations')
.from('deployment_provider_integrations' as any)
.select('id,scope')
.eq('deployment_provider_id', getDeploymentProviderResponse.data.id)
.eq('deployment_provider_id', (getDeploymentProviderResponse.data as DeploymentProvider).id)
.not('revoked_at', 'is', null)

if (getRevokedIntegrationsResponse.error) {
return new Response('Failed to get revoked integrations', { status: 500 })
}

const revokedIntegration = getRevokedIntegrationsResponse.data.find(
(ri) => (ri.scope as { organizationId: string }).organizationId === organization.id
)
const revokedIntegrations = (getRevokedIntegrationsResponse.data || []) as {
id: string
scope: { organizationId: string }
}[]

const revokedIntegration = revokedIntegrations.find((ri) => ri.scope.organizationId === organization.id)

const adminClient = createAdminClient()

Expand All @@ -145,22 +173,22 @@ export async function GET(req: NextRequest) {

// if an existing revoked integration exists, update the tokens and cancel the revocation
if (revokedIntegration) {
const updateIntegrationResponse = await supabase
const updateIntegrationResponse = await (supabase as any)
.from('deployment_provider_integrations')
.update({
credentials: credentialsSecret.data,
revoked_at: null,
})
.eq('id', revokedIntegration.id)
.eq('id', revokedIntegration.id as string)

if (updateIntegrationResponse.error) {
return new Response('Failed to update integration', { status: 500 })
}
} else {
const createIntegrationResponse = await supabase
const createIntegrationResponse = await (supabase as any)
.from('deployment_provider_integrations')
.insert({
deployment_provider_id: getDeploymentProviderResponse.data.id,
deployment_provider_id: (getDeploymentProviderResponse.data as DeploymentProvider).id,
credentials: credentialsSecret.data,
scope: {
organizationId: organization.id,
Expand Down
Loading