Joshenlim/fe 4474 add command to generate types off api production (#50960)

## Context
Adds a pnpm command `api:codegen:prod` to generate API types off prod to
work with the `verify-production-types` GHA. Just uses the existing
logic in `verify-production-types.mjs` to fetch the OpenAPI specs, and
writes it into the local API types files

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* API types can now be generated from the production API specifications
when a local API environment is unavailable.
* **Bug Fixes**
* Resending invitations and updating project-scoped roles now handle
roles without a base role ID more reliably.
* **Documentation**
* Updated guidance clarifies that API or schema changes must be deployed
before relying on updated types.
* Production is the source of truth for merge checks. Type verification
should pass after deployment, and production-generated types are
expected to pass verification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-09-29 01:34:34 +08:00
1 parent ae7b9ee245
commit 54c2a2788f
6 files changed
+132 -44

No files matched your search

+4 -2
View File
@@ -10,11 +10,13 @@ The generated API contract has three specs: API v1, API v2, and Platform. Their
## Update types
1. Make the API/schema change and ensure it is deployed to production before relying on a type PR. Production is the merge-gate source of truth.
2. Run `pnpm api:codegen` against a running local API environment. It fetches all three local OpenAPI specs and updates the committed files.
2. Update the committed types, either:
- Run `pnpm api:codegen` against a running local API environment. It fetches all three local OpenAPI specs and updates the committed files.
- Or, if you don't have a local API environment running, run `pnpm api:codegen:prod`. It fetches the three production OpenAPI specs directly and overwrites the committed files with them (no diffing — it always writes).
3. Inspect and commit only the intended generated type changes.
4. Run `pnpm api:verify-types`. It fetches the three production OpenAPI specs, regenerates types with the repository tooling, and compares them with the committed files.
Complete the update only when `pnpm api:verify-types` passes after the production deployment is available.
Complete the update only when `pnpm api:verify-types` passes after the production deployment is available. If you used `api:codegen:prod`, this should already pass since the committed files came straight from production.
## Interpret verification
+1
View File
@@ -51,6 +51,7 @@
"setup:cli": "supabase start -x studio && supabase status --output json > keys.json && node scripts/generateLocalEnv.js",
"generate:types": "supabase gen types typescript --local > ./supabase/functions/common/database-types.ts",
"api:codegen": "cd packages/api-types && pnpm run codegen",
"api:codegen:prod": "pnpm --filter=api-types run codegen:prod",
"api:verify-types": "pnpm --filter=api-types run verify-production-types",
"knip": "knip",
"authorize-vercel-deploys": "tsx scripts/authorizeVercelDeploys.ts"
+1
View File
@@ -8,6 +8,7 @@
"preinstall": "npx only-allow pnpm",
"clean": "rimraf .turbo tsconfig.tsbuildinfo",
"codegen": "openapi-typescript --redocly ./redocly.yaml --alphabetize --default-non-nullable=false && prettier --cache --write types/*.d.ts",
"codegen:prod": "node ./scripts/generate-production-types.mjs",
"verify-production-types": "node ./scripts/verify-production-types.mjs",
"test": "node --test ./scripts/*.test.mjs"
},
@@ -0,0 +1,28 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { generateTypes, specifications } from './verify-production-types.mjs'
const packageDirectory = dirname(dirname(fileURLToPath(import.meta.url)))
const typesDirectory = process.env.API_TYPES_DIRECTORY ?? join(packageDirectory, 'types')
// Regenerates the committed API types directly from the production OpenAPI specifications,
// bypassing the need for a running local API (unlike `codegen`, which reads from localhost).
export async function generateProductionTypes() {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'api-types-prod-'))
try {
await generateTypes(specifications, {
temporaryDirectory,
generatedTypesDirectory: typesDirectory,
})
} finally {
await rm(temporaryDirectory, { force: true, recursive: true })
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await generateProductionTypes()
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { generateTypes } from './verify-production-types.mjs'
const specifications = [{ name: 'api-v1', url: 'https://example.com/api/v1-json' }]
test('generateTypes fetches specifications and generates types straight into the given directory', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'api-types-generate-'))
t.after(() => rm(directory, { recursive: true, force: true }))
const temporaryDirectory = join(directory, 'fetched')
const generatedTypesDirectory = join(directory, 'output')
await mkdir(temporaryDirectory)
await mkdir(generatedTypesDirectory)
const runCalls = []
await generateTypes(specifications, {
temporaryDirectory,
generatedTypesDirectory,
fetchImpl: async (url) => ({ ok: true, text: async () => `spec from ${url}` }),
runImpl: async (command, args) => {
runCalls.push([command, args])
if (args.includes('--find-config-path')) return { stdout: '/repo/.prettierrc\n' }
return { stdout: '' }
},
})
const specification = await readFile(join(temporaryDirectory, 'api-v1.json'), 'utf8')
assert.match(specification, /v1-json/)
const redocly = await readFile(join(temporaryDirectory, 'redocly.yaml'), 'utf8')
assert.match(redocly, new RegExp(`output: ${generatedTypesDirectory}/api-v1.d.ts`))
assert.equal(runCalls.length, 3)
assert.deepEqual(runCalls[0][0], 'pnpm')
assert.ok(runCalls[0][1].includes('openapi-typescript'))
assert.ok(runCalls[1][1].includes('--find-config-path'))
assert.ok(runCalls[2][1].includes('--write'))
assert.ok(runCalls[2][1].includes(join(generatedTypesDirectory, 'api-v1.d.ts')))
})
@@ -12,7 +12,7 @@ const platformApiUrl =
process.env.PLATFORM_API_OPENAPI_URL ?? 'https://api.supabase.com/api/platform-json'
const fetchTimeout = 30_000
const specifications = [
export const specifications = [
{ name: 'api-v1', url: 'https://api.supabase.com/api/v1-json' },
{ name: 'api-v2', url: 'https://api.supabase.com/api/v2-json' },
{ name: 'platform', url: platformApiUrl },
@@ -116,53 +116,66 @@ export async function reportTypeDifferences(
if (summaryPath) await appendFile(summaryPath, `${summary.join('\n')}\n`)
}
// Fetches the given OpenAPI specifications and generates types for them into
// `generatedTypesDirectory`, formatted with the repository's Prettier config. Shared by
// `verifyProductionTypes` (which generates into a throwaway directory to diff against the
// committed types) and `generate-production-types.mjs` (which generates directly into the
// committed types directory).
export async function generateTypes(
specifications,
{ temporaryDirectory, generatedTypesDirectory, runImpl = run, fetchImpl, writeFileImpl }
) {
const config = await fetchOpenApiSpecifications(specifications, {
temporaryDirectory,
generatedTypesDirectory,
fetchImpl,
writeFileImpl,
})
await writeFile(join(temporaryDirectory, 'redocly.yaml'), `apis:\n${config.join('\n')}`)
await runImpl(
'pnpm',
[
'exec',
'openapi-typescript',
'--redocly',
join(temporaryDirectory, 'redocly.yaml'),
'--alphabetize',
'--default-non-nullable=false',
],
{ cwd: packageDirectory }
)
// Prettier resolves its config from the formatted file's location. The generated files live
// in a temporary directory outside the repository, so pass the repository config explicitly
// or they are formatted with Prettier's defaults and never match the committed files.
const { stdout: prettierConfigPath } = await runImpl(
'pnpm',
['exec', 'prettier', '--find-config-path', join(packageDirectory, 'package.json')],
{ cwd: packageDirectory }
)
await runImpl(
'pnpm',
[
'exec',
'prettier',
'--config',
prettierConfigPath.trim(),
'--write',
...specifications.map(({ name }) => join(generatedTypesDirectory, `${name}.d.ts`)),
],
{ cwd: packageDirectory }
)
}
export async function verifyProductionTypes() {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'api-types-'))
const generatedTypesDirectory = join(temporaryDirectory, 'types')
try {
await mkdir(generatedTypesDirectory)
const config = await fetchOpenApiSpecifications(specifications, {
temporaryDirectory,
generatedTypesDirectory,
})
await writeFile(join(temporaryDirectory, 'redocly.yaml'), `apis:\n${config.join('\n')}`)
await run(
'pnpm',
[
'exec',
'openapi-typescript',
'--redocly',
join(temporaryDirectory, 'redocly.yaml'),
'--alphabetize',
'--default-non-nullable=false',
],
{ cwd: packageDirectory }
)
// Prettier resolves its config from the formatted file's location. The generated files live
// in a temporary directory outside the repository, so pass the repository config explicitly
// or they are formatted with Prettier's defaults and never match the committed files.
const { stdout: prettierConfigPath } = await run(
'pnpm',
['exec', 'prettier', '--find-config-path', join(packageDirectory, 'package.json')],
{ cwd: packageDirectory }
)
await run(
'pnpm',
[
'exec',
'prettier',
'--config',
prettierConfigPath.trim(),
'--write',
...specifications.map(({ name }) => join(generatedTypesDirectory, `${name}.d.ts`)),
],
{ cwd: packageDirectory }
)
await generateTypes(specifications, { temporaryDirectory, generatedTypesDirectory })
const changedTypes = await findMismatchedTypes(specifications, {
generatedTypesDirectory,