diff --git a/.agents/skills/api-types/SKILL.md b/.agents/skills/api-types/SKILL.md index f9aefbc9bb6..0cef4dc41ec 100644 --- a/.agents/skills/api-types/SKILL.md +++ b/.agents/skills/api-types/SKILL.md @@ -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 diff --git a/package.json b/package.json index 0d18297f1da..18f9385ff47 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/api-types/package.json b/packages/api-types/package.json index f230553959b..2f05083e14a 100644 --- a/packages/api-types/package.json +++ b/packages/api-types/package.json @@ -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" }, diff --git a/packages/api-types/scripts/generate-production-types.mjs b/packages/api-types/scripts/generate-production-types.mjs new file mode 100644 index 00000000000..78105d38a9c --- /dev/null +++ b/packages/api-types/scripts/generate-production-types.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() +} diff --git a/packages/api-types/scripts/generate-production-types.test.mjs b/packages/api-types/scripts/generate-production-types.test.mjs new file mode 100644 index 00000000000..cf29896f75b --- /dev/null +++ b/packages/api-types/scripts/generate-production-types.test.mjs @@ -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'))) +}) diff --git a/packages/api-types/scripts/verify-production-types.mjs b/packages/api-types/scripts/verify-production-types.mjs index 3451a824853..03b7673e712 100644 --- a/packages/api-types/scripts/verify-production-types.mjs +++ b/packages/api-types/scripts/verify-production-types.mjs @@ -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,