diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index 84b13a9115..9ca434895b 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -41,7 +41,7 @@ yarn twenty auth:login yarn twenty entity:add # Start dev mode: watches, builds, and syncs local changes to your workspace -# (also auto-generates a typed API client in node_modules/twenty-sdk/generated) +# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated) yarn twenty app:dev # Watch your application's function logs @@ -110,7 +110,7 @@ In interactive mode, you can pick from: - Use `yarn twenty auth:login` to authenticate with your Twenty workspace. - Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills). - Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time. -- Types are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`. +- Two typed API clients are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`). ## Publish your application diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index 9916da78b2..fadba84e42 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -163,7 +163,7 @@ export default defineObject({ Later commands will add more files and folders: -- `yarn twenty app:dev` will auto-generate a typed API client in `node_modules/twenty-sdk/generated` (typed Twenty client + workspace types). +- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`). - `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more. ## Authentication @@ -423,10 +423,10 @@ Each function file uses `defineLogicFunction()` to export a configuration with a // src/app/createPostCard.logic-function.ts import { defineLogicFunction } from 'twenty-sdk'; import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk'; -import Twenty, { type Person } from '~/generated'; +import { CoreApiClient, type Person } from 'twenty-sdk/generated'; const handler = async (params: RoutePayload) => { - const client = new Twenty(); // generated typed client + const client = new CoreApiClient(); const name = 'name' in params.queryStringParameters ? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world' : 'Hello world'; @@ -635,10 +635,10 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS ```typescript // src/logic-functions/enrich-company.logic-function.ts import { defineLogicFunction } from 'twenty-sdk'; -import Twenty from '~/generated'; +import { CoreApiClient } from 'twenty-sdk/generated'; const handler = async (params: { companyName: string; domain?: string }) => { - const client = new Twenty(); + const client = new CoreApiClient(); const result = await client.mutation({ createTask: { @@ -761,18 +761,24 @@ You can create new skills in two ways: - **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill. - **Manual**: Create a new file and use `defineSkill()`, following the same pattern. -### Generated typed client +### Generated typed clients -The typed client is auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema. Use it in your functions: +Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema: + +- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data +- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads ```typescript -import Twenty from '~/generated'; +import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated'; -const client = new Twenty(); +const client = new CoreApiClient(); const { me } = await client.query({ me: { id: true, displayName: true } }); + +const metadataClient = new MetadataApiClient(); +const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } }); ``` -The client is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change. +Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change. #### Runtime credentials in logic functions @@ -788,17 +794,17 @@ Notes: #### Uploading files -The generated `Twenty` client includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood. +The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood. ```typescript -import Twenty from '~/generated'; +import { MetadataApiClient } from 'twenty-sdk/generated'; import * as fs from 'fs'; -const client = new Twenty(); +const metadataClient = new MetadataApiClient(); const fileBuffer = fs.readFileSync('./invoice.pdf'); -const uploadedFile = await client.uploadFile( +const uploadedFile = await metadataClient.uploadFile( fileBuffer, // file contents as a Buffer 'invoice.pdf', // filename 'application/pdf', // MIME type (defaults to 'application/octet-stream') @@ -828,7 +834,7 @@ uploadFile( | `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object | Key points: -- The method sends the file to the **metadata endpoint** (not the main GraphQL endpoint), where the upload mutation is resolved. +- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint. - It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else. - The returned `url` is a signed URL you can use to access the uploaded file. diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index 3b05ccceab..c016ad60c6 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -14,7 +14,7 @@ A CLI and SDK to develop, build, and publish applications that extend [Twenty CRM](https://twenty.com). -- Type‑safe client and workspace entity typings +- Two auto‑generated typed GraphQL clients: `CoreApiClient` (workspace data) and `MetadataApiClient` (workspace configuration & file uploads) - Built‑in CLI for auth, dev mode (watch & sync), uninstall, and function management - Works great with the scaffolder: [create-twenty-app](https://www.npmjs.com/package/create-twenty-app) diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 8a069dc85d..ec426ea90d 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -44,6 +44,16 @@ "types": "./generated/index.ts", "import": "./generated/index.ts", "require": "./generated/index.ts" + }, + "./generated/core": { + "types": "./generated/core/index.ts", + "import": "./generated/core/index.ts", + "require": "./generated/core/index.ts" + }, + "./generated/metadata": { + "types": "./generated/metadata/index.ts", + "import": "./generated/metadata/index.ts", + "require": "./generated/metadata/index.ts" } }, "license": "AGPL-3.0", @@ -118,6 +128,12 @@ ], "generated": [ "generated/index.ts" + ], + "generated/core": [ + "generated/core/index.ts" + ], + "generated/metadata": [ + "generated/metadata/index.ts" ] } } diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts index bf9f7b1d5f..dd9424bbaa 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -434,6 +434,19 @@ export class ApiService { async getSchema(options?: { authToken?: string; }): Promise> { + return this.introspectEndpoint('/graphql', options); + } + + async getMetadataSchema(options?: { + authToken?: string; + }): Promise> { + return this.introspectEndpoint('/metadata', options); + } + + private async introspectEndpoint( + endpoint: string, + options?: { authToken?: string }, + ): Promise> { try { const introspectionQuery = getIntrospectionQuery(); @@ -447,7 +460,7 @@ export class ApiService { } const response = await this.client.post( - '/graphql', + endpoint, { query: introspectionQuery, }, @@ -466,7 +479,7 @@ export class ApiService { return { success: true, data: printSchema(schema), - message: 'Successfully load schema', + message: `Successfully loaded schema from ${endpoint}`, }; } catch (error) { if (axios.isAxiosError(error) && error.response) { @@ -474,7 +487,7 @@ export class ApiService { success: false, error: error.response.data.errors[0]?.message || - 'Failed to load graphql Schema', + `Failed to load schema from ${endpoint}`, }; } throw error; diff --git a/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts b/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts index 78bdde4000..08fa4dd5c3 100644 --- a/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts +++ b/packages/twenty-sdk/src/cli/utilities/client/__tests__/clientServiceGeneratedClientAuth.test.ts @@ -24,7 +24,6 @@ import { ClientService } from '@/cli/utilities/client/client-service'; type TwentyClassType = new (options?: { url?: string; - metadataUrl?: string; fetch?: typeof globalThis.fetch; }) => { query: (request: Record) => Promise; @@ -49,7 +48,6 @@ export type GraphqlOperation = Record export type ClientOptions = { url?: string - metadataUrl?: string headers?: HeadersInit | (() => HeadersInit | Promise) fetcher?: (operation: GraphqlOperation | GraphqlOperation[]) => Promise fetch?: typeof globalThis.fetch @@ -128,9 +126,20 @@ describe('ClientService generated Twenty auth behavior', () => { const clientService = new ClientService(); await ( clientService as unknown as { - injectTwentyClient: (output: string) => Promise; + injectClientWrapper: ( + output: string, + options: { + className: string; + defaultUrl: string; + includeUploadFile: boolean; + }, + ) => Promise; } - ).injectTwentyClient(temporaryGeneratedClientDirectory); + ).injectClientWrapper(temporaryGeneratedClientDirectory, { + className: 'MetadataApiClient', + defaultUrl: '`${process.env.TWENTY_API_URL}/metadata`', + includeUploadFile: true, + }); const generatedIndexContent = await readFile( temporaryGeneratedIndexTsPath, @@ -153,7 +162,7 @@ describe('ClientService generated Twenty auth behavior', () => { `${pathToFileURL(temporaryGeneratedIndexMjsPath).href}?t=${Date.now()}` ); - TwentyClass = generatedModule.default as TwentyClassType; + TwentyClass = generatedModule.MetadataApiClient as TwentyClassType; }); beforeEach(() => { @@ -379,7 +388,6 @@ describe('ClientService generated Twenty auth behavior', () => { const twentyClient = new TwentyClass({ url: 'https://example.com/graphql', - metadataUrl: 'https://example.com/metadata', fetch: fetchMock as unknown as typeof globalThis.fetch, }); diff --git a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts index af5827cb07..4f22cc9fbe 100644 --- a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts @@ -3,12 +3,24 @@ import { generate } from '@genql/cli'; import * as fs from 'fs-extra'; import { join } from 'path'; import { - DEFAULT_APP_ACCESS_TOKEN_NAME, DEFAULT_API_KEY_NAME, DEFAULT_API_URL_NAME, + DEFAULT_APP_ACCESS_TOKEN_NAME, GENERATED_DIR, } from 'twenty-shared/application'; +type ClientWrapperOptions = { + className: string; + defaultUrl: string; + includeUploadFile: boolean; +}; + +const COMMON_SCALAR_TYPES = { + DateTime: 'string', + JSON: 'Record', + UUID: 'string', +}; + export class ClientService { private apiService: ApiService; @@ -26,30 +38,55 @@ export class ClientService { const outputPath = this.resolveGeneratedPath(appPath); const tempPath = `${outputPath}.tmp`; - const getSchemaResponse = await this.apiService.getSchema({ authToken }); + const [coreSchemaResponse, metadataSchemaResponse] = await Promise.all([ + this.apiService.getSchema({ authToken }), + this.apiService.getMetadataSchema({ authToken }), + ]); - if (!getSchemaResponse.success) { + if (!coreSchemaResponse.success) { throw new Error( - `Failed to introspect schema: ${JSON.stringify(getSchemaResponse.error)}`, + `Failed to introspect core schema: ${JSON.stringify(coreSchemaResponse.error)}`, ); } - const { data: schema } = getSchemaResponse; + if (!metadataSchemaResponse.success) { + throw new Error( + `Failed to introspect metadata schema: ${JSON.stringify(metadataSchemaResponse.error)}`, + ); + } await fs.ensureDir(tempPath); await fs.emptyDir(tempPath); - await generate({ - schema, - output: tempPath, - scalarTypes: { - DateTime: 'string', - JSON: 'Record', - UUID: 'string', - }, + await Promise.all([ + generate({ + schema: coreSchemaResponse.data, + output: join(tempPath, 'core'), + scalarTypes: COMMON_SCALAR_TYPES, + }), + generate({ + schema: metadataSchemaResponse.data, + output: join(tempPath, 'metadata'), + scalarTypes: { + ...COMMON_SCALAR_TYPES, + Upload: 'File', + }, + }), + ]); + + await this.injectClientWrapper(join(tempPath, 'core'), { + className: 'CoreApiClient', + defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``, + includeUploadFile: false, }); - await this.injectTwentyClient(tempPath); + await this.injectClientWrapper(join(tempPath, 'metadata'), { + className: 'MetadataApiClient', + defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``, + includeUploadFile: true, + }); + + await this.writeBarrelIndex(tempPath); await fs.remove(outputPath); await fs.move(tempPath, outputPath); @@ -59,19 +96,89 @@ export class ClientService { return join(appPath, 'node_modules', 'twenty-sdk', GENERATED_DIR); } - private async injectTwentyClient(output: string) { - const twentyClientContent = ` + private async writeBarrelIndex(outputDir: string): Promise { + const barrelContent = `export { CoreApiClient } from './core/index'; +export { MetadataApiClient } from './metadata/index'; +`; + + await fs.writeFile(join(outputDir, 'index.ts'), barrelContent); + } + + private async injectClientWrapper( + output: string, + options: ClientWrapperOptions, + ): Promise { + const clientContent = this.buildClientWrapperTemplate(options); + + await fs.appendFile(join(output, 'index.ts'), clientContent); + } + + private buildClientWrapperTemplate(options: ClientWrapperOptions): string { + const { className, defaultUrl, includeUploadFile } = options; + + const uploadFileMethod = includeUploadFile + ? ` + async uploadFile( + fileBuffer: Buffer, + filename: string, + contentType: string = 'application/octet-stream', + fieldMetadataUniversalIdentifier: string, + ): Promise<{ + id: string; + path: string; + size: number; + createdAt: string; + url: string; + }> { + const form = new FormData(); + + form.append( + 'operations', + JSON.stringify({ + query: \`mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) { + uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url } + }\`, + variables: { file: null, fieldMetadataUniversalIdentifier }, + }), + ); + form.append('map', JSON.stringify({ '0': ['variables.file'] })); + form.append('0', new Blob([fileBuffer], { type: contentType }), filename); + + const result = await this.executeGraphqlRequestWithOptionalRefresh({ + operation: form, + headers: {}, + requestInit: { + method: 'POST', + }, + }); + + if (result.errors) { + throw new GenqlError(result.errors, result.data); + } + + const data = result.data as Record; + + return data.uploadFilesFieldFileByUniversalIdentifier as { + id: string; + path: string; + size: number; + createdAt: string; + url: string; + } + } +` + : ''; + + return ` // ---------------------------------------------------- -// Custom Twenty client (auto-injected) +// ${className} (auto-injected) // ---------------------------------------------------- const APP_ACCESS_TOKEN_ENV_KEY = '${DEFAULT_APP_ACCESS_TOKEN_NAME}'; const API_KEY_ENV_KEY = '${DEFAULT_API_KEY_NAME}'; -type TwentyClientOptions = ClientOptions & { - metadataUrl?: string; -} +type ${className}Options = ClientOptions type ProcessEnvironment = Record @@ -157,39 +264,35 @@ const hasAuthenticationErrorInGraphqlPayload = ( return payload.errors.some((error) => { return ( error.extensions?.code === 'UNAUTHENTICATED' || - // Fallback for payloads that don't provide structured error codes. error.message?.toLowerCase() === 'unauthorized' ); }); } -const defaultOptions: TwentyClientOptions = { - url: \`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\`, - metadataUrl: \`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\`, +const defaultOptions: ${className}Options = { + url: ${defaultUrl}, headers: { 'Content-Type': 'application/json', }, } -export default class Twenty { +export class ${className} { private client: Client; private url: string; - private metadataUrl: string; private requestOptions: RequestInit; private headers: HeadersInit | (() => HeadersInit | Promise); private fetchImplementation: typeof globalThis.fetch | null; private authorizationToken: string | null; private refreshAccessTokenPromise: Promise | null = null; - constructor(options?: TwentyClientOptions) { - const merged: TwentyClientOptions = { + constructor(options?: ${className}Options) { + const merged: ${className}Options = { ...defaultOptions, ...options, } const { url, - metadataUrl, headers, fetch: customFetchImplementation, fetcher: _fetcher, @@ -198,7 +301,6 @@ export default class Twenty { } = merged; this.url = url ?? ''; - this.metadataUrl = metadataUrl ?? this.url.replace(/\\/graphql$/, '/metadata'); this.requestOptions = requestOptions; this.headers = headers ?? {}; this.fetchImplementation = customFetchImplementation ?? globalThis.fetch ?? null; @@ -232,71 +334,18 @@ export default class Twenty { mutation(request: R & { __name?: string }) { return this.client.mutation(request); } - - async uploadFile( - fileBuffer: Buffer, - filename: string, - contentType: string = 'application/octet-stream', - fieldMetadataUniversalIdentifier: string, - ): Promise<{ - id: string; - path: string; - size: number; - createdAt: string; - url: string; - }> { - const form = new FormData(); - - form.append( - 'operations', - JSON.stringify({ - query: \`mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) { - uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url } - }\`, - variables: { file: null, fieldMetadataUniversalIdentifier }, - }), - ); - form.append('map', JSON.stringify({ '0': ['variables.file'] })); - form.append('0', new Blob([fileBuffer], { type: contentType }), filename); - - const result = await this.executeGraphqlRequestWithOptionalRefresh({ - operation: form, - url: this.metadataUrl, - headers: {}, - requestInit: { - method: 'POST', - }, - }); - - if (result.errors) { - throw new GenqlError(result.errors, result.data); - } - - const data = result.data as Record; - - return data.uploadFilesFieldFileByUniversalIdentifier as { - id: string; - path: string; - size: number; - createdAt: string; - url: string; - } - } - +${uploadFileMethod} private async executeGraphqlRequestWithOptionalRefresh({ operation, - url = this.url, headers, requestInit, }: { operation: GraphqlOperation | GraphqlOperation[] | FormData; - url?: string; headers?: HeadersInit; requestInit?: RequestInit; }) { const firstResponse = await this.executeGraphqlRequest({ operation, - url, headers, requestInit, token: this.authorizationToken, @@ -308,7 +357,6 @@ export default class Twenty { if (refreshedAccessToken) { const retryResponse = await this.executeGraphqlRequest({ operation, - url, headers, requestInit, token: refreshedAccessToken, @@ -323,13 +371,11 @@ export default class Twenty { private async executeGraphqlRequest({ operation, - url, headers, requestInit, token, }: { operation: GraphqlOperation | GraphqlOperation[] | FormData; - url: string; headers?: HeadersInit; requestInit?: RequestInit; token: string | null; @@ -359,7 +405,7 @@ export default class Twenty { requestHeaders.delete('Authorization'); } - const response = await this.fetchImplementation.call(globalThis, url, { + const response = await this.fetchImplementation.call(globalThis, this.url, { ...this.requestOptions, ...requestInit, method: requestInit?.method ?? 'POST', @@ -465,7 +511,5 @@ export default class Twenty { } `; - - await fs.appendFile(join(output, 'index.ts'), twentyClientContent); } } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts index 2982e8ae60..338ef7fa0e 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step.ts @@ -13,7 +13,12 @@ import { } from 'twenty-shared/application'; import { FileFolder } from 'twenty-shared/types'; -const API_CLIENT_FILES = ['types.ts', 'schema.ts']; +const API_CLIENT_FILES = [ + 'core/types.ts', + 'core/schema.ts', + 'metadata/types.ts', + 'metadata/schema.ts', +]; export type UploadFilesOrchestratorStepOutput = { fileUploader: FileUploader | null;