[SDK] Refactor clients (#18433)

# Intoduction

Closes https://github.com/twentyhq/core-team-issues/issues/2289

In this PR all the clients becomes available under `twenty-sdk/clients`,
this is a breaking change but generated was too vague and thats still
the now or never best timing to do so

## CoreClient
The core client is now shipped with a default stub empty class for both
the schema and the client
Allowing its import, will still raises typescript errors when consumed
as generated but not generated

## MetadataClient
The metadata client is workspace agnostic, it's now generated and
commited in the repo. added a ci that prevents any schema desync due to
twenty-server additions

Same behavior than for the twenty-front generated graphql schema
This commit is contained in:
Paul Rastoin
2026-03-09 16:32:13 +01:00
committed by GitHub
parent 82a1179e23
commit 75bb3a904d
68 changed files with 25487 additions and 1212 deletions
+21 -4
View File
@@ -25,6 +25,7 @@ jobs:
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-sdk/src/clients/generated/metadata/**
packages/twenty-emails/**
packages/twenty-shared/**
@@ -158,8 +159,10 @@ jobs:
exit 1
fi
- name: GraphQL / Check for Pending Generation
- name: Check for Pending Code Generation
run: |
HAS_ERRORS=false
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
@@ -171,11 +174,25 @@ jobs:
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata
echo "==================================================="
echo ""
echo "Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
exit 1
HAS_ERRORS=true
fi
npx nx run twenty-sdk:generate-metadata-client
if ! git diff --quiet -- packages/twenty-sdk/src/clients/generated/metadata; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-sdk:generate-metadata-client' and commit the changes."
echo ""
echo "The following SDK metadata client changes were detected:"
echo "==================================================="
git diff -- packages/twenty-sdk/src/clients/generated/metadata
echo "==================================================="
echo ""
HAS_ERRORS=true
fi
if [ "$HAS_ERRORS" = true ]; then
exit 1
fi
server-test:
needs: server-build
timeout-minutes: 30
+2 -2
View File
@@ -43,7 +43,7 @@ yarn twenty auth:login
yarn twenty entity:add
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
# (also auto-generates typed CoreApiClient MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`)
yarn twenty app:dev
# Watch your application's function logs
@@ -107,7 +107,7 @@ npx create-twenty-app@latest my-app -m
- 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.
- Two typed API clients are autogenerated 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`).
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty app:dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
## Publish your application
@@ -47,18 +47,17 @@ describe('scaffoldIntegrationTest', () => {
const content = await fs.readFile(testPath, 'utf8');
expect(content).toContain(
"import { appGenerateClient, appUninstall } from 'twenty-sdk/cli'",
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-sdk/generated'",
"import { MetadataApiClient } from 'twenty-sdk/clients'",
);
expect(content).toContain(
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
);
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('assertServerIsReachable');
expect(content).toContain('appGenerateClient');
expect(content).toContain('appBuild');
expect(content).toContain('appUninstall');
expect(content).toContain('new MetadataApiClient()');
expect(content).toContain('findManyApplications');
expect(content).toContain('APPLICATION_UNIVERSAL_IDENTIFIER');
});
@@ -84,7 +83,8 @@ describe('scaffoldIntegrationTest', () => {
expect(content).toContain('.twenty-sdk-test');
expect(content).toContain('config.json');
expect(content).toContain('process.env.TWENTY_API_URL');
expect(content).toContain('process.env.TWENTY_TEST_API_KEY');
expect(content).toContain('process.env.TWENTY_API_KEY');
expect(content).toContain('assertServerIsReachable');
});
});
@@ -101,7 +101,8 @@ describe('scaffoldIntegrationTest', () => {
const content = await fs.readFile(vitestConfigPath, 'utf8');
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_API_KEY');
expect(content).not.toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('setup-test.ts');
expect(content).toContain('tsconfig.spec.json');
@@ -45,7 +45,7 @@ export default defineConfig({
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_TEST_API_KEY:
TWENTY_API_KEY:
'${SEED_API_KEY}',
},
},
@@ -93,16 +93,36 @@ import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
beforeAll(() => {
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
} catch {
throw new Error(
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(\`Server at \${TWENTY_API_URL} returned \${response.status}\`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_TEST_API_KEY,
apiKey: process.env.TWENTY_API_KEY,
},
},
};
@@ -128,44 +148,24 @@ const createIntegrationTest = async ({
fileName: string;
}) => {
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appGenerateClient, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
} catch {
throw new Error(
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(\`Server at \${TWENTY_API_URL} returned \${response.status}\`);
}
};
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
await assertServerIsReachable();
const generateResult = await appGenerateClient({
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) => console.log(\`[generate-client] \${message}\`),
onProgress: (message: string) => console.log(\`[build] \${message}\`),
});
if (!generateResult.success) {
if (!buildResult.success) {
throw new Error(
\`Client generation failed: \${generateResult.error?.message ?? 'Unknown error'}\`,
\`Build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
);
}
@@ -187,20 +187,7 @@ describe('App installation', () => {
});
it('should find the installed app in the applications list', async () => {
const apiKey = process.env.TWENTY_TEST_API_KEY;
if (!apiKey) {
throw new Error(
'No API key found. Set TWENTY_TEST_API_KEY in your vitest config env.',
);
}
const metadataClient = new MetadataApiClient({
url: \`\${TWENTY_API_URL}/metadata\`,
headers: {
Authorization: \`Bearer \${apiKey}\`,
},
});
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
+10 -29
View File
@@ -1,38 +1,19 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
+1
View File
@@ -4,6 +4,7 @@
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
@@ -1,6 +1,6 @@
{
"name": "@twentyhq/hello-world",
"version": "0.2.2",
"name": "hello-world",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -20,7 +20,7 @@
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"twenty-sdk": "0.6.3",
"twenty-sdk": "0.6.4",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
@@ -1,43 +1,22 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appGenerateClient, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
await assertServerIsReachable();
const generateResult = await appGenerateClient({
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) =>
console.log(`[generate-client] ${message}`),
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!generateResult.success) {
if (!buildResult.success) {
throw new Error(
`Client generation failed: ${generateResult.error?.message ?? 'Unknown error'}`,
`Build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
@@ -59,20 +38,7 @@ describe('App installation', () => {
});
it('should find the installed app in the applications list', async () => {
const apiKey = process.env.TWENTY_TEST_API_KEY;
if (!apiKey) {
throw new Error(
'No API key found. Set TWENTY_TEST_API_KEY in your vitest config env.',
);
}
const metadataClient = new MetadataApiClient({
url: `${TWENTY_API_URL}/metadata`,
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
@@ -3,16 +3,36 @@ import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
beforeAll(() => {
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_TEST_API_KEY,
apiKey: process.env.TWENTY_API_KEY,
},
},
};
@@ -2,7 +2,7 @@ import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'1badae7c-8a42-4dea-b4b8-3c56e77c2f9a';
'6563e091-9f5b-4026-a3ea-7e3b3d09e218';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
@@ -3,7 +3,7 @@ import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'
export default defineField({
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: '2c503a0d-36c9-49ec-b82f-4fafe0eb6f47',
universalIdentifier: '770d32c2-cf12-4ab2-b66d-73f92dc239b5',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
@@ -10,7 +10,7 @@ export const HelloWorld = () => {
};
export default defineFrontComponent({
universalIdentifier: '26c17445-fbfb-4b34-99d6-f461e734ca97',
universalIdentifier: 'd371f098-5b2c-42f0-898d-94459f1ee337',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@@ -5,7 +5,7 @@ const handler = async (): Promise<{ message: string }> => {
};
export default defineLogicFunction({
universalIdentifier: '4f0b7137-1399-4e50-ac00-3c3bb2555c38',
universalIdentifier: '2baa26eb-9aaf-4856-a4f4-30d6fd6480ee',
name: 'hello-world-logic-function',
description: 'A simple logic function',
timeoutSeconds: 5,
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePostInstallLogicFunction({
universalIdentifier: 'c1410017-8536-42aa-a188-4bfc5a1c3dae',
universalIdentifier: '7a3f4684-51db-494d-833b-a747a3b90507',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePreInstallLogicFunction({
universalIdentifier: '68d005d4-1110-4fa0-8227-71e06d6b9f30',
universalIdentifier: '1272ffdb-8e2f-492c-ab37-66c2b97e9c23',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
@@ -1,14 +1,11 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '574a895f-1511-4b38-9d28-d6b8436738ff',
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',
position: 0,
// Link to a view:
// viewUniversalIdentifier: '...',
// Or link to an object:
// targetObjectUniversalIdentifier: '...',
// Or link to an external URL:
// link: 'https://example.com',
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,10 +1,10 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
'b75cfe84-18ce-47da-812a-53e25ee094af';
'dfd43356-39b3-4b55-b4a7-279bec689928';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'6ab9c690-06ce-455e-a2c9-8067a9747f96';
'd2d7f6cd-33f6-456f-bf00-17adeca926ba';
export default defineObject({
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'f14afc30-f2fa-4f70-9b12-903c5f852225';
'9238bc7b-d38f-4a1c-9d19-31ab7bc67a2f';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'4f00dd76-c07b-4d55-a43a-7f17e7f6440a';
'd0940029-9d3c-40be-903a-52d65393028f';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
@@ -1,10 +1,22 @@
import { defineView } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { defineView, ViewKey } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = 'e004df40-29f3-47ba-b39d-d3a5c444367a';
export default defineView({
universalIdentifier: 'e574b32c-c058-492a-8a5c-780b844a8735',
name: 'example-view',
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All example items',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '496c40c2-5766-419c-93bf-20fdad3f34bb',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
],
});
@@ -15,7 +15,7 @@ export default defineConfig({
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_TEST_API_KEY:
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
},
File diff suppressed because it is too large Load Diff
@@ -169,7 +169,7 @@ export default defineObject({
Later commands will add more files and folders:
- `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 app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/clients`: `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
@@ -431,7 +431,7 @@ 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 { CoreApiClient, type Person } from 'twenty-sdk/generated';
import { CoreApiClient, type Person } from 'twenty-sdk/clients';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -669,7 +669,7 @@ 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 { CoreApiClient } from 'twenty-sdk/generated';
import { CoreApiClient } from 'twenty-sdk/clients';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
@@ -830,13 +830,14 @@ You can create new agents in two ways:
### Generated typed clients
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/clients` 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 { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
import { CoreApiClient } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-sdk/clients';
const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
@@ -845,7 +846,7 @@ const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
`CoreApiClient` is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change. `MetadataApiClient` ships pre-built with the SDK.
#### Runtime credentials in logic functions
@@ -861,10 +862,10 @@ Notes:
#### Uploading files
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.
The `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 { MetadataApiClient } from 'twenty-sdk/generated';
import { MetadataApiClient } from 'twenty-sdk/clients';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
+1 -1
View File
@@ -1,6 +1,6 @@
node_modules
.twenty
generated
/generated
storybook-static
src/front-component-renderer/__stories__/example-sources-built
src/front-component-renderer/__stories__/example-sources-built-preact
+1 -1
View File
@@ -4,7 +4,7 @@
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"ignorePatterns": ["node_modules", "dist", "src/clients/generated"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
+1
View File
@@ -1,3 +1,4 @@
dist
storybook-static
coverage
src/clients/generated
+3 -6
View File
@@ -14,7 +14,7 @@
A CLI and SDK to develop, build, and publish applications that extend [Twenty CRM](https://twenty.com).
- Two autogenerated typed GraphQL clients: `CoreApiClient` (workspace data) and `MetadataApiClient` (workspace configuration & file uploads)
- Typed GraphQL clients: `CoreApiClient` (auto-generated per app for workspace data) and `MetadataApiClient` (pre-built with the SDK for workspace configuration & file uploads)
- Builtin 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)
@@ -54,8 +54,7 @@ Commands:
auth:switch Switch the default workspace
auth:list List all configured workspaces
app:dev Watch and sync local application changes
app:generate-client Build, sync to local server, and generate the typed API client
app:build Build the application (no server needed)
app:build Build, sync, and generate API client
app:publish Build and publish to npm or a Twenty server
app:typecheck Run TypeScript type checking on the application
app:uninstall Uninstall application from Twenty
@@ -133,9 +132,7 @@ Application development commands.
- Behavior: Builds your application (functions and front components), computes the manifest, syncs everything to your workspace, then watches the directory for changes and re-syncs automatically. Displays an interactive UI showing build and sync status in real time. Press Ctrl+C to stop.
- `twenty app:generate-client [appPath]` — One-shot build, sync to local server, and generate the typed API client. Requires a running local server.
- `twenty app:build [appPath]` — Build the application into `.twenty/output/`. No server needed.
- `twenty app:build [appPath]` — Build the application, sync to the server, generate the typed API client, then rebuild with the real client.
- Options:
- `--tarball`: Also pack the output into a `.tgz` tarball.
+6 -23
View File
@@ -9,7 +9,6 @@
},
"files": [
"dist",
"generated",
"README.md",
"package.json"
],
@@ -45,20 +44,10 @@
"import": "./dist/front-component-renderer/index.mjs",
"require": "./dist/front-component-renderer/index.cjs"
},
"./generated": {
"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"
"./clients": {
"types": "./dist/clients/index.d.ts",
"import": "./dist/clients.mjs",
"require": "./dist/clients.cjs"
}
},
"license": "AGPL-3.0",
@@ -127,14 +116,8 @@
"front-component-renderer": [
"dist/front-component-renderer/index.d.ts"
],
"generated": [
"generated/index.ts"
],
"generated/core": [
"generated/core/index.ts"
],
"generated/metadata": [
"generated/metadata/index.ts"
"clients": [
"dist/clients/index.d.ts"
]
}
}
+10
View File
@@ -95,6 +95,16 @@
"command": "npx vite build -c vite.config.sdk.ts"
}
},
"generate-metadata-client": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/src/clients/generated/metadata"],
"options": {
"cwd": "packages/twenty-sdk",
"command": "tsx -r tsconfig-paths/register scripts/generate-metadata-client.ts"
}
},
"generate-remote-dom-elements": {
"executor": "nx:run-commands",
"cache": true,
@@ -0,0 +1,43 @@
import { readFile } from 'node:fs/promises';
import path from 'path';
import { CLIENTS_GENERATED_DIR } from '@/cli/constants/clients-dir';
import { ClientService } from '@/cli/utilities/client/client-service';
const TEMPLATE_PATH = path.resolve(
__dirname,
'..',
'src',
'cli',
'utilities',
'client',
'twenty-client-template.ts',
);
const main = async () => {
const outputPath = path.resolve(
__dirname,
'..',
CLIENTS_GENERATED_DIR,
'metadata',
);
const serverUrl = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const token = process.env.TWENTY_API_KEY;
const clientWrapperTemplateSource = await readFile(TEMPLATE_PATH, 'utf-8');
const clientService = new ClientService({
clientWrapperTemplateSource,
serverUrl,
token,
});
await clientService.generateMetadataClient({ outputPath });
console.log(`Metadata client generated at ${outputPath}`);
};
main().catch((error) => {
console.error('Failed to generate metadata client:', error);
process.exit(1);
});
@@ -1,7 +1,7 @@
import { resolve } from 'path';
import { vi } from 'vitest';
import { appGenerateClient } from '@/cli/public-operations/app-generate-client';
import { appBuild } from '@/cli/public-operations/app-build';
import { appUninstall } from '@/cli/public-operations/app-uninstall';
import { functionExecute } from '@/cli/public-operations/function-execute';
import { ADD_NUMBERS_UNIVERSAL_IDENTIFIER } from '../src/logic-functions/add-numbers.function';
@@ -10,17 +10,16 @@ const APP_PATH = resolve(__dirname, '../');
describe('functionExecute E2E', () => {
beforeAll(async () => {
const generateResult = await appGenerateClient({ appPath: APP_PATH });
const buildResult = await appBuild({ appPath: APP_PATH });
if (!generateResult.success) {
if (!buildResult.success) {
throw new Error(
`appGenerateClient failed: ${generateResult.error.code} ${generateResult.error.message}`,
`appBuild failed: ${buildResult.error.code} ${buildResult.error.message}`,
);
}
// Although appGenerateClient uploads files before syncing the manifest, the server
// may need a moment to make them readable by the execution engine.
// Retry a dummy execution until the handler file becomes available.
// The server may need a moment to make uploaded files readable
// by the execution engine. Retry until the handler becomes available.
await vi.waitFor(
async () => {
const result = await functionExecute({
@@ -14,7 +14,7 @@ beforeAll(async () => {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_TEST_API_KEY,
apiKey: process.env.TWENTY_API_KEY,
},
},
};
@@ -2,7 +2,6 @@ import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { AppBuildCommand } from './app/app-build';
import { AppGenerateClientCommand } from './app/app-generate-client';
import { AppDevCommand } from './app/app-dev';
import { AppPublishCommand } from './app/app-publish';
import { AppTypecheckCommand } from './app/app-typecheck';
@@ -64,7 +63,6 @@ export const registerCommands = (program: Command): void => {
// App commands
const buildCommand = new AppBuildCommand();
const generateClientCommand = new AppGenerateClientCommand();
const devCommand = new AppDevCommand();
const publishCommand = new AppPublishCommand();
const typecheckCommand = new AppTypecheckCommand();
@@ -82,22 +80,9 @@ export const registerCommands = (program: Command): void => {
});
});
program
.command('app:generate-client [appPath]')
.description(
'Build, sync to local server, and generate the typed API client',
)
.action(async (appPath) => {
await generateClientCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('app:build [appPath]')
.description(
'Build the application into .twenty/output/ (no server needed)',
)
.description('Build, sync, and generate API client into .twenty/output/')
.option('--tarball', 'Also pack into a .tgz tarball')
.action(async (appPath, options) => {
await buildCommand.execute({
@@ -1,33 +0,0 @@
import { appGenerateClient } from '@/cli/public-operations/app-generate-client';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
export type AppGenerateClientCommandOptions = {
appPath?: string;
};
export class AppGenerateClientCommand {
async execute(options: AppGenerateClientCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Generating API client...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appGenerateClient({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(
chalk.green(
`✓ Client generated (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
),
);
}
}
@@ -0,0 +1,2 @@
export const CLIENTS_SOURCE_DIR = 'src/clients';
export const CLIENTS_GENERATED_DIR = `${CLIENTS_SOURCE_DIR}/generated`;
@@ -2,10 +2,9 @@ import { execSync } from 'child_process';
import path from 'path';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { synchronizeBuiltApplication } from '@/cli/utilities/build/common/synchronize-built-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { ClientService } from '@/cli/utilities/client/client-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from './types';
@@ -47,26 +46,31 @@ const innerAppBuild = async (
onProgress?.(`${warning}`);
}
const clientService = new ClientService();
await clientService.ensureGeneratedClientStub({ appPath });
onProgress?.('Building application files...');
const buildResult = await buildApplication({
const firstBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Updating manifest checksums...');
onProgress?.('Syncing application schema...');
const updatedManifest = manifestUpdateChecksums({
const firstSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: buildResult.builtFileInfos,
builtFileInfos: firstBuildResult.builtFileInfos,
});
await writeManifestToOutput(appPath, updatedManifest);
if (!firstSyncResult.success) {
return firstSyncResult;
}
onProgress?.('Generating API client...');
const clientService = new ClientService();
await clientService.generateCoreClient({ appPath });
onProgress?.('Running typecheck...');
@@ -87,11 +91,31 @@ const innerAppBuild = async (
};
}
onProgress?.('Rebuilding with generated client...');
const finalBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing built files...');
const finalSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: finalBuildResult.builtFileInfos,
});
if (!finalSyncResult.success) {
return finalSyncResult;
}
const outputDir = path.join(appPath, '.twenty', 'output');
const result: AppBuildResult = {
outputDir,
fileCount: buildResult.builtFileInfos.size,
fileCount: finalBuildResult.builtFileInfos.size,
};
if (options.tarball) {
@@ -1,100 +0,0 @@
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { synchronizeBuiltApplication } from '@/cli/utilities/build/common/synchronize-built-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { ClientService } from '@/cli/utilities/client/client-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppGenerateClientOptions = {
appPath: string;
onProgress?: (message: string) => void;
};
export type AppGenerateClientResult = {
fileCount: number;
};
const innerAppGenerateClient = async (
options: AppGenerateClientOptions,
): Promise<CommandResult<AppGenerateClientResult>> => {
const { appPath, onProgress } = options;
onProgress?.('Building manifest...');
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED,
message: manifestResult.errors.join('\n'),
},
};
}
const { manifest, filePaths } = manifestResult;
for (const warning of manifestResult.warnings) {
onProgress?.(`${warning}`);
}
const clientService = new ClientService();
await clientService.ensureGeneratedClientStub({ appPath });
onProgress?.('Building application files...');
const buildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing application schema...');
const syncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: buildResult.builtFileInfos,
});
if (!syncResult.success) {
return syncResult;
}
onProgress?.('Generating API client...');
await clientService.generate({ appPath });
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
if (typecheckErrors.length > 0) {
const errorMessages = typecheckErrors.map(
(error) =>
`${error.file}(${error.line},${error.column + 1}): ${error.text}`,
);
return {
success: false,
error: {
code: APP_ERROR_CODES.TYPECHECK_FAILED,
message: `Typecheck failed:\n${errorMessages.join('\n')}`,
},
};
}
return {
success: true,
data: {
fileCount: buildResult.builtFileInfos.size,
},
};
};
export const appGenerateClient = (
options: AppGenerateClientOptions,
): Promise<CommandResult<AppGenerateClientResult>> =>
runSafe(() => innerAppGenerateClient(options), APP_ERROR_CODES.SYNC_FAILED);
@@ -24,8 +24,7 @@ const innerAppUninstall = async (
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message:
'Manifest not found. Run `app:build`, `app:generate-client`, or `app:dev` first.',
message: 'Manifest not found. Run `app:build` or `app:dev` first.',
},
};
}
@@ -61,8 +61,7 @@ const innerFunctionExecute = async (
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message:
'Manifest not found. Run `app:build`, `app:generate-client`, or `app:dev` first.',
message: 'Manifest not found. Run `app:build` or `app:dev` first.',
},
};
}
@@ -7,11 +7,6 @@ export type { AuthLogoutOptions } from './auth-logout';
// App
export { appBuild } from './app-build';
export type { AppBuildOptions, AppBuildResult } from './app-build';
export { appGenerateClient } from './app-generate-client';
export type {
AppGenerateClientOptions,
AppGenerateClientResult,
} from './app-generate-client';
export { appPublish } from './app-publish';
export type { AppPublishOptions, AppPublishResult } from './app-publish';
export { appUninstall } from './app-uninstall';
@@ -11,7 +11,7 @@ import { FileFolder } from 'twenty-shared/types';
import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build';
import {
LOGIC_FUNCTION_EXTERNAL_MODULES,
createSdkGeneratedResolverPlugin,
createSdkClientsResolverPlugin,
} from '@/cli/utilities/build/common/esbuild-watcher';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '@/cli/utilities/build/common/front-component-build/constants/front-component-external-modules';
import { getFrontComponentBuildPlugins } from '@/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins';
@@ -80,7 +80,7 @@ export const buildApplication = async (
metafile: true,
logLevel: 'silent',
banner: NODE_ESM_CJS_BANNER,
plugins: [createSdkGeneratedResolverPlugin(options.appPath)],
plugins: [createSdkClientsResolverPlugin(options.appPath)],
},
onFileBuilt: collectFileBuilt,
});
@@ -102,7 +102,7 @@ export const buildApplication = async (
metafile: true,
logLevel: 'silent',
plugins: [
createSdkGeneratedResolverPlugin(options.appPath),
createSdkClientsResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(),
],
},
@@ -1,3 +1,4 @@
import { CLIENTS_SOURCE_DIR } from '@/cli/constants/clients-dir';
import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files';
import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '@/cli/utilities/build/common/front-component-build/constants/front-component-external-modules';
@@ -11,11 +12,7 @@ import {
import { createTypecheckPlugin } from '@/cli/utilities/build/common/typecheck-plugin';
import * as esbuild from 'esbuild';
import path from 'path';
import {
GENERATED_DIR,
NODE_ESM_CJS_BANNER,
OUTPUT_DIR,
} from 'twenty-shared/application';
import { NODE_ESM_CJS_BANNER, OUTPUT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
export const LOGIC_FUNCTION_EXTERNAL_MODULES: string[] = [
@@ -189,19 +186,19 @@ export class EsbuildWatcher implements RestartableWatcher {
}
}
// Resolves twenty-sdk/generated to the actual file path so esbuild
// Resolves twenty-sdk/clients to the source barrel so esbuild
// bundles it instead of treating it as external (via twenty-sdk/*)
export const createSdkGeneratedResolverPlugin = (
export const createSdkClientsResolverPlugin = (
appPath: string,
): esbuild.Plugin => ({
name: 'sdk-generated-resolver',
name: 'sdk-clients-resolver',
setup: (build) => {
build.onResolve({ filter: /^twenty-sdk\/generated/ }, () => ({
build.onResolve({ filter: /^twenty-sdk\/clients/ }, () => ({
path: path.join(
appPath,
'node_modules',
'twenty-sdk',
GENERATED_DIR,
CLIENTS_SOURCE_DIR,
'index.ts',
),
}));
@@ -223,7 +220,7 @@ export const createLogicFunctionsWatcher = (
platform: 'node',
extraPlugins: [
createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createSdkGeneratedResolverPlugin(options.appPath),
createSdkClientsResolverPlugin(options.appPath),
],
banner: NODE_ESM_CJS_BANNER,
},
@@ -240,7 +237,7 @@ export const createFrontComponentsWatcher = (
jsx: 'automatic',
extraPlugins: [
createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createSdkGeneratedResolverPlugin(options.appPath),
createSdkClientsResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(),
],
},
@@ -1,14 +1,14 @@
import { conditionalAvailabilityTransformPlugin } from '@/cli/utilities/build/common/conditional-availability/conditional-availability-transform-plugin';
import { type ValidationResult } from '@/sdk';
import { pathExists, remove } from '@/cli/utilities/file/fs-utils';
import { type ValidationResult } from '@/sdk';
import * as esbuild from 'esbuild';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { createRequire } from 'module';
import { mkdtemp, writeFile } from 'node:fs/promises';
import os from 'os';
import path from 'path';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
const MANIFEST_MOCK_MODULES = ['twenty-sdk/ui', 'twenty-sdk/generated'];
const MANIFEST_MOCK_MODULES = ['twenty-sdk/ui', 'twenty-sdk/clients'];
const manifestMockPlugin: esbuild.Plugin = {
name: 'manifest-mock',
@@ -1,19 +1,14 @@
import path, { relative } from 'path';
import chokidar, { type FSWatcher } from 'chokidar';
import { type EventName } from 'chokidar/handler.js';
import { ASSETS_DIR, GENERATED_DIR } from 'twenty-shared/application';
import { ASSETS_DIR } from 'twenty-shared/application';
export type ManifestWatcherOptions = {
appPath: string;
handleChangeDetected: (filePath: string) => void;
};
const IGNORED_DIRECTORY_NAMES = new Set([
'node_modules',
GENERATED_DIR,
'dist',
'.twenty',
]);
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', 'dist', '.twenty']);
export class ManifestWatcher {
private appPath: string;
@@ -13,11 +13,14 @@ import {
vi,
} from 'vitest';
vi.mock('@/cli/constants/clients-dir', () => ({
CLIENTS_GENERATED_DIR: 'src/clients/generated',
}));
vi.mock('twenty-shared/application', () => ({
DEFAULT_APP_ACCESS_TOKEN_NAME: 'TWENTY_APP_ACCESS_TOKEN',
DEFAULT_API_KEY_NAME: 'TWENTY_API_KEY',
DEFAULT_API_URL_NAME: 'TWENTY_API_URL',
GENERATED_DIR: 'generated',
}));
import { ClientService } from '@/cli/utilities/client/client-service';
@@ -1,17 +1,17 @@
import { appendFile, writeFile } from 'node:fs/promises';
import { appendFile } from 'node:fs/promises';
import { join } from 'path';
import { CLIENTS_GENERATED_DIR } from '@/cli/constants/clients-dir';
import { ApiService } from '@/cli/utilities/api/api-service';
import twentyClientTemplateSource from '@/cli/utilities/client/twenty-client-template.ts?raw';
import {
emptyDir,
ensureDir,
move,
pathExists,
remove,
} from '@/cli/utilities/file/fs-utils';
import twentyClientTemplateSource from '@/cli/utilities/client/twenty-client-template.ts?raw';
import { generate } from '@genql/cli';
import { DEFAULT_API_URL_NAME, GENERATED_DIR } from 'twenty-shared/application';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
type ClientWrapperOptions = {
apiClientName: string;
@@ -30,8 +30,11 @@ const STRIPPED_TYPES_END = '// __STRIPPED_DURING_INJECTION_END__';
const UPLOAD_FILE_START = '// __UPLOAD_FILE_START__';
const UPLOAD_FILE_END = '// __UPLOAD_FILE_END__';
const buildClientWrapperSource = (options: ClientWrapperOptions): string => {
let source = twentyClientTemplateSource;
const buildClientWrapperSource = (
templateSource: string,
options: ClientWrapperOptions,
): string => {
let source = templateSource;
source = source.replace(
new RegExp(
@@ -70,25 +73,39 @@ const escapeRegExp = (value: string): string =>
export class ClientService {
private apiService: ApiService;
private clientWrapperTemplateSource: string;
constructor() {
this.apiService = new ApiService({ disableInterceptors: true });
constructor(options?: {
clientWrapperTemplateSource?: string;
serverUrl?: string;
token?: string;
}) {
this.clientWrapperTemplateSource =
options?.clientWrapperTemplateSource ?? twentyClientTemplateSource;
this.apiService = new ApiService({
disableInterceptors: true,
serverUrl: options?.serverUrl,
token: options?.token,
});
}
async generate({
async generateCoreClient({
appPath,
authToken,
}: {
appPath: string;
authToken?: string;
}): Promise<void> {
const outputPath = this.resolveGeneratedPath(appPath);
const tempPath = `${outputPath}.tmp`;
const generatedDir = join(
appPath,
'node_modules',
'twenty-sdk',
CLIENTS_GENERATED_DIR,
);
const coreOutputPath = join(generatedDir, 'core');
const tempPath = `${coreOutputPath}.tmp`;
const [coreSchemaResponse, metadataSchemaResponse] = await Promise.all([
this.apiService.getSchema({ authToken }),
this.apiService.getMetadataSchema({ authToken }),
]);
const coreSchemaResponse = await this.apiService.getSchema({ authToken });
if (!coreSchemaResponse.success) {
throw new Error(
@@ -96,93 +113,65 @@ export class ClientService {
);
}
await ensureDir(tempPath);
await emptyDir(tempPath);
await generate({
schema: coreSchemaResponse.data,
output: tempPath,
scalarTypes: COMMON_SCALAR_TYPES,
});
await this.injectClientWrapper(tempPath, {
apiClientName: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: true,
});
await remove(coreOutputPath);
await move(tempPath, coreOutputPath);
}
async generateMetadataClient({
outputPath,
}: {
outputPath: string;
}): Promise<void> {
const metadataSchemaResponse = await this.apiService.getMetadataSchema();
if (!metadataSchemaResponse.success) {
throw new Error(
`Failed to introspect metadata schema: ${JSON.stringify(metadataSchemaResponse.error)}`,
);
}
await ensureDir(tempPath);
await emptyDir(tempPath);
await ensureDir(outputPath);
await emptyDir(outputPath);
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'), {
apiClientName: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: true,
await generate({
schema: metadataSchemaResponse.data,
output: outputPath,
scalarTypes: {
...COMMON_SCALAR_TYPES,
Upload: 'File',
},
});
await this.injectClientWrapper(join(tempPath, 'metadata'), {
await this.injectClientWrapper(outputPath, {
apiClientName: 'MetadataApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``,
includeUploadFile: true,
});
await this.writeBarrelIndex(tempPath);
await remove(outputPath);
await move(tempPath, outputPath);
}
async ensureGeneratedClientStub({
appPath,
}: {
appPath: string;
}): Promise<void> {
const outputPath = this.resolveGeneratedPath(appPath);
if (await pathExists(join(outputPath, 'index.ts'))) {
return;
}
await ensureDir(join(outputPath, 'core'));
await ensureDir(join(outputPath, 'metadata'));
await writeFile(
join(outputPath, 'core', 'index.ts'),
'export class CoreApiClient {}\n',
);
await writeFile(
join(outputPath, 'metadata', 'index.ts'),
'export class MetadataApiClient {}\n',
);
await this.writeBarrelIndex(outputPath);
}
private resolveGeneratedPath(appPath: string): string {
return join(appPath, 'node_modules', 'twenty-sdk', GENERATED_DIR);
}
private async writeBarrelIndex(outputDir: string): Promise<void> {
const barrelContent = `export { CoreApiClient } from './core/index';
export { MetadataApiClient } from './metadata/index';
export * as CoreSchema from './core/schema';
export * as MetadataSchema from './metadata/schema';
`;
await writeFile(join(outputDir, 'index.ts'), barrelContent);
}
private async injectClientWrapper(
output: string,
options: ClientWrapperOptions,
): Promise<void> {
const clientContent = buildClientWrapperSource(options);
const clientContent = buildClientWrapperSource(
this.clientWrapperTemplateSource,
options,
);
await appendFile(join(output, 'index.ts'), clientContent);
}
@@ -90,10 +90,6 @@ export class DevModeOrchestrator {
await ensureDir(outputDir);
await emptyDir(outputDir);
await this.clientService.ensureGeneratedClientStub({
appPath: this.state.appPath,
});
await this.startWatchersStep.start();
this.serverCheckInterval = setInterval(() => {
@@ -34,7 +34,7 @@ export class GenerateApiClientOrchestratorStep {
try {
const config = await this.configService.getConfig();
await this.clientService.generate({
await this.clientService.generateCoreClient({
appPath: input.appPath,
authToken: config.applicationAccessToken,
});
@@ -7,19 +7,11 @@ import { copy, ensureDir, pathExists } from '@/cli/utilities/file/fs-utils';
import crypto from 'crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'path';
import {
OUTPUT_DIR,
GENERATED_DIR,
API_CLIENT_DIR,
} from 'twenty-shared/application';
import { CLIENTS_GENERATED_DIR } from '@/cli/constants/clients-dir';
import { OUTPUT_DIR, API_CLIENT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
const API_CLIENT_FILES = [
'core/types.ts',
'core/schema.ts',
'metadata/types.ts',
'metadata/schema.ts',
];
const API_CLIENT_FILES = ['core/types.ts', 'core/schema.ts'];
export type UploadFilesOrchestratorStepOutput = {
fileUploader: FileUploader | null;
@@ -124,7 +116,7 @@ export class UploadFilesOrchestratorStep {
appPath,
'node_modules',
'twenty-sdk',
GENERATED_DIR,
CLIENTS_GENERATED_DIR,
);
if (!(await pathExists(generatedDir))) {
@@ -0,0 +1,2 @@
// Stub — overwritten by `twenty app:build` or `twenty app:dev`
export class CoreApiClient {}
@@ -0,0 +1,2 @@
// Stub — overwritten by `twenty app:build` or `twenty app:dev`
export type CoreSchema = {};
@@ -0,0 +1,482 @@
// @ts-nocheck
import type {
QueryGenqlSelection,
Query,
MutationGenqlSelection,
Mutation,
SubscriptionGenqlSelection,
Subscription,
} from './schema'
import {
linkTypeMap,
createClient as createClientOriginal,
generateGraphqlOperation,
type FieldsSelection,
type GraphqlOperation,
type ClientOptions,
GenqlError,
} from './runtime'
export type { FieldsSelection } from './runtime'
export { GenqlError }
import types from './types'
export * from './schema'
const typeMap = linkTypeMap(types as any)
export interface Client {
query<R extends QueryGenqlSelection>(
request: R & { __name?: string },
): Promise<FieldsSelection<Query, R>>
mutation<R extends MutationGenqlSelection>(
request: R & { __name?: string },
): Promise<FieldsSelection<Mutation, R>>
}
export const createClient = function (options?: ClientOptions): Client {
return createClientOriginal({
url: undefined,
...options,
queryRoot: typeMap.Query!,
mutationRoot: typeMap.Mutation!,
subscriptionRoot: typeMap.Subscription!,
}) as any
}
export const everything = {
__scalar: true,
}
export type QueryResult<fields extends QueryGenqlSelection> = FieldsSelection<
Query,
fields
>
export const generateQueryOp: (
fields: QueryGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation('query', typeMap.Query!, fields as any)
}
export type MutationResult<fields extends MutationGenqlSelection> =
FieldsSelection<Mutation, fields>
export const generateMutationOp: (
fields: MutationGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation('mutation', typeMap.Mutation!, fields as any)
}
export type SubscriptionResult<fields extends SubscriptionGenqlSelection> =
FieldsSelection<Subscription, fields>
export const generateSubscriptionOp: (
fields: SubscriptionGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation(
'subscription',
typeMap.Subscription!,
fields as any,
)
}
// MetadataApiClient (auto-injected by twenty-sdk)
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
type MetadataApiClientOptions = ClientOptions;
type ProcessEnvironment = Record<string, string | undefined>;
type GraphqlErrorPayloadEntry = {
message?: string;
extensions?: { code?: string };
};
type GraphqlResponsePayload = {
data?: Record<string, unknown>;
errors?: GraphqlErrorPayloadEntry[];
};
type GraphqlResponse = {
status: number;
statusText: string;
payload: GraphqlResponsePayload | null;
rawBody: string;
};
const getProcessEnvironment = (): ProcessEnvironment => {
const processObject = (
globalThis as { process?: { env?: ProcessEnvironment } }
).process;
return processObject?.env ?? {};
};
const getTokenFromAuthorizationHeader = (
authorizationHeader: string | undefined,
): string | null => {
if (typeof authorizationHeader !== 'string') {
return null;
}
const trimmedAuthorizationHeader = authorizationHeader.trim();
if (trimmedAuthorizationHeader.length === 0) {
return null;
}
if (trimmedAuthorizationHeader === 'Bearer') {
return null;
}
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
}
return trimmedAuthorizationHeader;
};
const getTokenFromHeaders = (
headers: HeadersInit | undefined,
): string | null => {
if (!headers) {
return null;
}
if (headers instanceof Headers) {
return getTokenFromAuthorizationHeader(
headers.get('Authorization') ?? undefined,
);
}
if (Array.isArray(headers)) {
const matchedAuthorizationHeader = headers.find(
([headerName]) => headerName.toLowerCase() === 'authorization',
);
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
}
const headersRecord = headers as Record<string, string | undefined>;
return getTokenFromAuthorizationHeader(
headersRecord.Authorization ?? headersRecord.authorization,
);
};
const hasAuthenticationErrorInGraphqlPayload = (
payload: GraphqlResponsePayload | null,
): boolean => {
if (!payload?.errors) {
return false;
}
return payload.errors.some((graphqlError) => {
return (
graphqlError.extensions?.code === 'UNAUTHENTICATED' ||
graphqlError.message?.toLowerCase() === 'unauthorized'
);
});
};
const defaultOptions: MetadataApiClientOptions = {
url: `${process.env.TWENTY_API_URL}/metadata`,
headers: {
'Content-Type': 'application/json',
},
};
export class MetadataApiClient {
private client: Client;
private url: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: MetadataApiClientOptions) {
const merged: MetadataApiClientOptions = {
...defaultOptions,
...options,
};
const {
url,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
batch: _batch,
...requestOptions
} = merged;
this.url = url ?? '';
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation =
customFetchImplementation ?? globalThis.fetch ?? null;
const processEnvironment = getProcessEnvironment();
const tokenFromHeaders = getTokenFromHeaders(
typeof headers === 'function' ? undefined : headers,
);
// Priority: explicit header > app access token > api key (legacy).
this.authorizationToken =
tokenFromHeaders ??
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
processEnvironment[API_KEY_ENV_KEY] ??
null;
this.client = createClient({
...merged,
headers: undefined,
fetcher: async (operation) =>
this.executeGraphqlRequestWithOptionalRefresh({
operation,
}),
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(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 as BlobPart], { 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<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
};
}
private async executeGraphqlRequestWithOptionalRefresh({
operation,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: this.authorizationToken,
});
if (this.shouldRefreshToken(firstResponse)) {
const refreshedAccessToken = await this.requestRefreshedAccessToken();
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: refreshedAccessToken,
});
return this.assertResponseIsSuccessful(retryResponse);
}
}
return this.assertResponseIsSuccessful(firstResponse);
}
private async executeGraphqlRequest({
operation,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
}): Promise<GraphqlResponse> {
if (!this.fetchImplementation) {
throw new Error(
'Global `fetch` function is not available, ' +
'pass a fetch implementation to the Twenty client',
);
}
const resolvedHeaders = await this.resolveHeaders();
const requestHeaders = new Headers(resolvedHeaders);
if (headers) {
new Headers(headers).forEach((value, key) =>
requestHeaders.set(key, value),
);
}
if (operation instanceof FormData) {
requestHeaders.delete('Content-Type');
} else {
requestHeaders.set('Content-Type', 'application/json');
}
if (token) {
requestHeaders.set('Authorization', `Bearer ${token}`);
} else {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
headers: requestHeaders,
body:
operation instanceof FormData ? operation : JSON.stringify(operation),
});
const rawBody = await response.text();
let payload: GraphqlResponsePayload | null = null;
if (rawBody.trim().length > 0) {
try {
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
} catch {
payload = null;
}
}
return {
status: response.status,
statusText: response.statusText,
payload,
rawBody,
};
}
private async resolveHeaders(): Promise<HeadersInit> {
if (typeof this.headers === 'function') {
return (await this.headers()) ?? {};
}
return this.headers ?? {};
}
private shouldRefreshToken(response: GraphqlResponse): boolean {
if (response.status === 401) {
return true;
}
return hasAuthenticationErrorInGraphqlPayload(response.payload);
}
private assertResponseIsSuccessful(response: GraphqlResponse) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`${response.statusText}: ${response.rawBody}`);
}
if (response.payload === null) {
throw new Error('Invalid JSON response');
}
return response.payload;
}
private async requestRefreshedAccessToken(): Promise<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>;
};
}
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
if (typeof refreshAccessTokenFunction !== 'function') {
return null;
}
if (!this.refreshAccessTokenPromise) {
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
.then((refreshedAccessToken) => {
if (
typeof refreshedAccessToken !== 'string' ||
refreshedAccessToken.length === 0
) {
return null;
}
this.setAuthorizationToken(refreshedAccessToken);
return refreshedAccessToken;
})
.catch((refreshError: unknown) => {
console.error('Twenty client: token refresh failed', refreshError);
return null;
})
.finally(() => {
this.refreshAccessTokenPromise = null;
});
}
return this.refreshAccessTokenPromise;
}
private setAuthorizationToken(token: string) {
this.authorizationToken = token;
const processEnvironment = getProcessEnvironment();
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
}
}
@@ -0,0 +1,265 @@
// @ts-nocheck
import type { GraphqlOperation } from './generateGraphqlOperation'
import { GenqlError } from './error'
type Variables = Record<string, any>
type QueryError = Error & {
message: string
locations?: Array<{
line: number
column: number
}>
path?: any
rid: string
details?: Record<string, any>
}
type Result = {
data: Record<string, any>
errors: Array<QueryError>
}
type Fetcher = (
batchedQuery: GraphqlOperation | Array<GraphqlOperation>,
) => Promise<Array<Result>>
type Options = {
batchInterval?: number
shouldBatch?: boolean
maxBatchSize?: number
}
type Queue = Array<{
request: GraphqlOperation
resolve: (...args: Array<any>) => any
reject: (...args: Array<any>) => any
}>
/**
* takes a list of requests (queue) and batches them into a single server request.
* It will then resolve each individual requests promise with the appropriate data.
* @private
* @param {QueryBatcher} client - the client to use
* @param {Queue} queue - the list of requests to batch
*/
function dispatchQueueBatch(client: QueryBatcher, queue: Queue): void {
let batchedQuery: any = queue.map((item) => item.request)
if (batchedQuery.length === 1) {
batchedQuery = batchedQuery[0]
}
client.fetcher(batchedQuery).then((responses: any) => {
if (queue.length === 1 && !Array.isArray(responses)) {
if (responses.errors && responses.errors.length) {
queue[0].reject(
new GenqlError(responses.errors, responses.data),
)
return
}
queue[0].resolve(responses)
return
} else if (responses.length !== queue.length) {
throw new Error('response length did not match query length')
}
for (let i = 0; i < queue.length; i++) {
if (responses[i].errors && responses[i].errors.length) {
queue[i].reject(
new GenqlError(responses[i].errors, responses[i].data),
)
} else {
queue[i].resolve(responses[i])
}
}
})
}
/**
* creates a list of requests to batch according to max batch size.
* @private
* @param {QueryBatcher} client - the client to create list of requests from from
* @param {Options} options - the options for the batch
*/
function dispatchQueue(client: QueryBatcher, options: Options): void {
const queue = client._queue
const maxBatchSize = options.maxBatchSize || 0
client._queue = []
if (maxBatchSize > 0 && maxBatchSize < queue.length) {
for (let i = 0; i < queue.length / maxBatchSize; i++) {
dispatchQueueBatch(
client,
queue.slice(i * maxBatchSize, (i + 1) * maxBatchSize),
)
}
} else {
dispatchQueueBatch(client, queue)
}
}
/**
* Create a batcher client.
* @param {Fetcher} fetcher - A function that can handle the network requests to graphql endpoint
* @param {Options} options - the options to be used by client
* @param {boolean} options.shouldBatch - should the client batch requests. (default true)
* @param {integer} options.batchInterval - duration (in MS) of each batch window. (default 6)
* @param {integer} options.maxBatchSize - max number of requests in a batch. (default 0)
* @param {boolean} options.defaultHeaders - default headers to include with every request
*
* @example
* const fetcher = batchedQuery => fetch('path/to/graphql', {
* method: 'post',
* headers: {
* Accept: 'application/json',
* 'Content-Type': 'application/json',
* },
* body: JSON.stringify(batchedQuery),
* credentials: 'include',
* })
* .then(response => response.json())
*
* const client = new QueryBatcher(fetcher, { maxBatchSize: 10 })
*/
export class QueryBatcher {
fetcher: Fetcher
_options: Options
_queue: Queue
constructor(
fetcher: Fetcher,
{
batchInterval = 6,
shouldBatch = true,
maxBatchSize = 0,
}: Options = {},
) {
this.fetcher = fetcher
this._options = {
batchInterval,
shouldBatch,
maxBatchSize,
}
this._queue = []
}
/**
* Fetch will send a graphql request and return the parsed json.
* @param {string} query - the graphql query.
* @param {Variables} variables - any variables you wish to inject as key/value pairs.
* @param {[string]} operationName - the graphql operationName.
* @param {Options} overrides - the client options overrides.
*
* @return {promise} resolves to parsed json of server response
*
* @example
* client.fetch(`
* query getHuman($id: ID!) {
* human(id: $id) {
* name
* height
* }
* }
* `, { id: "1001" }, 'getHuman')
* .then(human => {
* // do something with human
* console.log(human);
* });
*/
fetch(
query: string,
variables?: Variables,
operationName?: string,
overrides: Options = {},
): Promise<Result> {
const request: GraphqlOperation = {
query,
}
const options = Object.assign({}, this._options, overrides)
if (variables) {
request.variables = variables
}
if (operationName) {
request.operationName = operationName
}
const promise = new Promise<Result>((resolve, reject) => {
this._queue.push({
request,
resolve,
reject,
})
if (this._queue.length === 1) {
if (options.shouldBatch) {
setTimeout(
() => dispatchQueue(this, options),
options.batchInterval,
)
} else {
dispatchQueue(this, options)
}
}
})
return promise
}
/**
* Fetch will send a graphql request and return the parsed json.
* @param {string} query - the graphql query.
* @param {Variables} variables - any variables you wish to inject as key/value pairs.
* @param {[string]} operationName - the graphql operationName.
* @param {Options} overrides - the client options overrides.
*
* @return {Promise<Array<Result>>} resolves to parsed json of server response
*
* @example
* client.forceFetch(`
* query getHuman($id: ID!) {
* human(id: $id) {
* name
* height
* }
* }
* `, { id: "1001" }, 'getHuman')
* .then(human => {
* // do something with human
* console.log(human);
* });
*/
forceFetch(
query: string,
variables?: Variables,
operationName?: string,
overrides: Options = {},
): Promise<Result> {
const request: GraphqlOperation = {
query,
}
const options = Object.assign({}, this._options, overrides, {
shouldBatch: false,
})
if (variables) {
request.variables = variables
}
if (operationName) {
request.operationName = operationName
}
const promise = new Promise<Result>((resolve, reject) => {
const client = new QueryBatcher(this.fetcher, this._options)
client._queue = [
{
request,
resolve,
reject,
},
]
dispatchQueue(client, options)
})
return promise
}
}
@@ -0,0 +1,68 @@
// @ts-nocheck
import { type BatchOptions, createFetcher } from './fetcher'
import type { ExecutionResult, LinkedType } from './types'
import {
generateGraphqlOperation,
type GraphqlOperation,
} from './generateGraphqlOperation'
export type Headers =
| HeadersInit
| (() => HeadersInit)
| (() => Promise<HeadersInit>)
export type BaseFetcher = (
operation: GraphqlOperation | GraphqlOperation[],
) => Promise<ExecutionResult | ExecutionResult[]>
export type ClientOptions = Omit<RequestInit, 'body' | 'headers'> & {
url?: string
batch?: BatchOptions | boolean
fetcher?: BaseFetcher
fetch?: Function
headers?: Headers
}
export const createClient = ({
queryRoot,
mutationRoot,
subscriptionRoot,
...options
}: ClientOptions & {
queryRoot?: LinkedType
mutationRoot?: LinkedType
subscriptionRoot?: LinkedType
}) => {
const fetcher = createFetcher(options)
const client: {
query?: Function
mutation?: Function
} = {}
if (queryRoot) {
client.query = (request: any) => {
if (!queryRoot) throw new Error('queryRoot argument is missing')
const resultPromise = fetcher(
generateGraphqlOperation('query', queryRoot, request),
)
return resultPromise
}
}
if (mutationRoot) {
client.mutation = (request: any) => {
if (!mutationRoot)
throw new Error('mutationRoot argument is missing')
const resultPromise = fetcher(
generateGraphqlOperation('mutation', mutationRoot, request),
)
return resultPromise
}
}
return client as any
}
@@ -0,0 +1,29 @@
// @ts-nocheck
export class GenqlError extends Error {
errors: Array<GraphqlError> = []
/**
* Partial data returned by the server
*/
data?: any
constructor(errors: any[], data: any) {
let message = Array.isArray(errors)
? errors.map((x) => x?.message || '').join('\n')
: ''
if (!message) {
message = 'GraphQL error'
}
super(message)
this.errors = errors
this.data = data
}
}
interface GraphqlError {
message: string
locations?: Array<{
line: number
column: number
}>
path?: string[]
extensions?: Record<string, any>
}
@@ -0,0 +1,98 @@
// @ts-nocheck
import { QueryBatcher } from './batcher'
import type { ClientOptions } from './createClient'
import type { GraphqlOperation } from './generateGraphqlOperation'
import { GenqlError } from './error'
export interface Fetcher {
(gql: GraphqlOperation): Promise<any>
}
export type BatchOptions = {
batchInterval?: number // ms
maxBatchSize?: number
}
const DEFAULT_BATCH_OPTIONS = {
maxBatchSize: 10,
batchInterval: 40,
}
export const createFetcher = ({
url,
headers = {},
fetcher,
fetch: _fetch,
batch = false,
...rest
}: ClientOptions): Fetcher => {
if (!url && !fetcher) {
throw new Error('url or fetcher is required')
}
if (!fetcher) {
fetcher = async (body) => {
let headersObject =
typeof headers == 'function' ? await headers() : headers
headersObject = headersObject || {}
if (typeof fetch === 'undefined' && !_fetch) {
throw new Error(
'Global `fetch` function is not available, pass a fetch polyfill to Genql `createClient`',
)
}
let fetchImpl = _fetch || fetch
const res = await fetchImpl(url!, {
headers: {
'Content-Type': 'application/json',
...headersObject,
},
method: 'POST',
body: JSON.stringify(body),
...rest,
})
if (!res.ok) {
throw new Error(`${res.statusText}: ${await res.text()}`)
}
const json = await res.json()
return json
}
}
if (!batch) {
return async (body) => {
const json = await fetcher!(body)
if (Array.isArray(json)) {
return json.map((json) => {
if (json?.errors?.length) {
throw new GenqlError(json.errors || [], json.data)
}
return json.data
})
} else {
if (json?.errors?.length) {
throw new GenqlError(json.errors || [], json.data)
}
return json.data
}
}
}
const batcher = new QueryBatcher(
async (batchedQuery) => {
// console.log(batchedQuery) // [{ query: 'query{user{age}}', variables: {} }, ...]
const json = await fetcher!(batchedQuery)
return json as any
},
batch === true ? DEFAULT_BATCH_OPTIONS : batch,
)
return async ({ query, variables }) => {
const json = await batcher.fetch(query, variables)
if (json?.data) {
return json.data
}
throw new Error(
'Genql batch fetcher returned unexpected result ' + JSON.stringify(json),
)
}
}
@@ -0,0 +1,225 @@
// @ts-nocheck
import type { LinkedField, LinkedType } from './types'
export interface Args {
[arg: string]: any | undefined
}
export interface Fields {
[field: string]: Request
}
export type Request = boolean | number | Fields
export interface Variables {
[name: string]: {
value: any
typing: [LinkedType, string]
}
}
export interface Context {
root: LinkedType
varCounter: number
variables: Variables
fragmentCounter: number
fragments: string[]
}
export interface GraphqlOperation {
query: string
variables?: { [name: string]: any }
operationName?: string
}
const parseRequest = (
request: Request | undefined,
ctx: Context,
path: string[],
): string => {
if (typeof request === 'object' && '__args' in request) {
const args: any = request.__args
let fields: Request | undefined = { ...request }
delete fields.__args
const argNames = Object.keys(args)
if (argNames.length === 0) {
return parseRequest(fields, ctx, path)
}
const field = getFieldFromPath(ctx.root, path)
const argStrings = argNames.map((argName) => {
ctx.varCounter++
const varName = `v${ctx.varCounter}`
const typing = field.args && field.args[argName] // typeMap used here, .args
if (!typing) {
throw new Error(
`no typing defined for argument \`${argName}\` in path \`${path.join(
'.',
)}\``,
)
}
ctx.variables[varName] = {
value: args[argName],
typing,
}
return `${argName}:$${varName}`
})
return `(${argStrings})${parseRequest(fields, ctx, path)}`
} else if (typeof request === 'object' && Object.keys(request).length > 0) {
const fields = request
const fieldNames = Object.keys(fields).filter((k) => Boolean(fields[k]))
if (fieldNames.length === 0) {
throw new Error(
`field selection should not be empty: ${path.join('.')}`,
)
}
const type =
path.length > 0 ? getFieldFromPath(ctx.root, path).type : ctx.root
const scalarFields = type.scalar
let scalarFieldsFragment: string | undefined
if (fieldNames.includes('__scalar')) {
const falsyFieldNames = new Set(
Object.keys(fields).filter((k) => !Boolean(fields[k])),
)
if (scalarFields?.length) {
ctx.fragmentCounter++
scalarFieldsFragment = `f${ctx.fragmentCounter}`
ctx.fragments.push(
`fragment ${scalarFieldsFragment} on ${
type.name
}{${scalarFields
.filter((f) => !falsyFieldNames.has(f))
.join(',')}}`,
)
}
}
const fieldsSelection = fieldNames
.filter((f) => !['__scalar', '__name'].includes(f))
.map((f) => {
const parsed = parseRequest(fields[f], ctx, [...path, f])
if (f.startsWith('on_')) {
ctx.fragmentCounter++
const implementationFragment = `f${ctx.fragmentCounter}`
const typeMatch = f.match(/^on_(.+)/)
if (!typeMatch || !typeMatch[1])
throw new Error('match failed')
ctx.fragments.push(
`fragment ${implementationFragment} on ${typeMatch[1]}${parsed}`,
)
return `...${implementationFragment}`
} else {
return `${f}${parsed}`
}
})
.concat(scalarFieldsFragment ? [`...${scalarFieldsFragment}`] : [])
.join(',')
return `{${fieldsSelection}}`
} else {
return ''
}
}
export const generateGraphqlOperation = (
operation: 'query' | 'mutation' | 'subscription',
root: LinkedType,
fields?: Fields,
): GraphqlOperation => {
const ctx: Context = {
root: root,
varCounter: 0,
variables: {},
fragmentCounter: 0,
fragments: [],
}
const result = parseRequest(fields, ctx, [])
const varNames = Object.keys(ctx.variables)
const varsString =
varNames.length > 0
? `(${varNames.map((v) => {
const variableType = ctx.variables[v].typing[1]
return `$${v}:${variableType}`
})})`
: ''
const operationName = fields?.__name || ''
return {
query: [
`${operation} ${operationName}${varsString}${result}`,
...ctx.fragments,
].join(','),
variables: Object.keys(ctx.variables).reduce<{ [name: string]: any }>(
(r, v) => {
r[v] = ctx.variables[v].value
return r
},
{},
),
...(operationName ? { operationName: operationName.toString() } : {}),
}
}
export const getFieldFromPath = (
root: LinkedType | undefined,
path: string[],
) => {
let current: LinkedField | undefined
if (!root) throw new Error('root type is not provided')
if (path.length === 0) throw new Error(`path is empty`)
path.forEach((f) => {
const type = current ? current.type : root
if (!type.fields)
throw new Error(`type \`${type.name}\` does not have fields`)
const possibleTypes = Object.keys(type.fields)
.filter((i) => i.startsWith('on_'))
.reduce(
(types, fieldName) => {
const field = type.fields && type.fields[fieldName]
if (field) types.push(field.type)
return types
},
[type],
)
let field: LinkedField | null = null
possibleTypes.forEach((type) => {
const found = type.fields && type.fields[f]
if (found) field = found
})
if (!field)
throw new Error(
`type \`${type.name}\` does not have a field \`${f}\``,
)
current = field
})
return current as LinkedField
}
@@ -0,0 +1,13 @@
// @ts-nocheck
export { createClient } from './createClient'
export type { ClientOptions } from './createClient'
export type { FieldsSelection } from './typeSelection'
export { generateGraphqlOperation } from './generateGraphqlOperation'
export type { GraphqlOperation } from './generateGraphqlOperation'
export { linkTypeMap } from './linkTypeMap'
// export { Observable } from 'zen-observable-ts'
export { createFetcher } from './fetcher'
export { GenqlError } from './error'
export const everything = {
__scalar: true,
}
@@ -0,0 +1,139 @@
// @ts-nocheck
import type {
CompressedType,
CompressedTypeMap,
LinkedArgMap,
LinkedField,
LinkedType,
LinkedTypeMap,
} from './types'
export interface PartialLinkedFieldMap {
[field: string]: {
type: string
args?: LinkedArgMap
}
}
export const linkTypeMap = (
typeMap: CompressedTypeMap<number>,
): LinkedTypeMap => {
const indexToName: Record<number, string> = Object.assign(
{},
...Object.keys(typeMap.types).map((k, i) => ({ [i]: k })),
)
let intermediaryTypeMap = Object.assign(
{},
...Object.keys(typeMap.types || {}).map(
(k): Record<string, LinkedType> => {
const type: CompressedType = typeMap.types[k]!
const fields = type || {}
return {
[k]: {
name: k,
// type scalar properties
scalar: Object.keys(fields).filter((f) => {
const [type] = fields[f] || []
return type && typeMap.scalars.includes(type)
}),
// fields with corresponding `type` and `args`
fields: Object.assign(
{},
...Object.keys(fields).map(
(f): PartialLinkedFieldMap => {
const [typeIndex, args] = fields[f] || []
if (typeIndex == null) {
return {}
}
return {
[f]: {
// replace index with type name
type: indexToName[typeIndex],
args: Object.assign(
{},
...Object.keys(args || {}).map(
(k) => {
// if argTypeString == argTypeName, argTypeString is missing, need to readd it
if (!args || !args[k]) {
return
}
const [
argTypeName,
argTypeString,
] = args[k] as any
return {
[k]: [
indexToName[
argTypeName
],
argTypeString ||
indexToName[
argTypeName
],
],
}
},
),
),
},
}
},
),
),
},
}
},
),
)
const res = resolveConcreteTypes(intermediaryTypeMap)
return res
}
// replace typename with concrete type
export const resolveConcreteTypes = (linkedTypeMap: LinkedTypeMap) => {
Object.keys(linkedTypeMap).forEach((typeNameFromKey) => {
const type: LinkedType = linkedTypeMap[typeNameFromKey]!
// type.name = typeNameFromKey
if (!type.fields) {
return
}
const fields = type.fields
Object.keys(fields).forEach((f) => {
const field: LinkedField = fields[f]!
if (field.args) {
const args = field.args
Object.keys(args).forEach((key) => {
const arg = args[key]
if (arg) {
const [typeName] = arg
if (typeof typeName === 'string') {
if (!linkedTypeMap[typeName]) {
linkedTypeMap[typeName] = { name: typeName }
}
arg[0] = linkedTypeMap[typeName]!
}
}
})
}
const typeName = field.type as LinkedType | string
if (typeof typeName === 'string') {
if (!linkedTypeMap[typeName]) {
linkedTypeMap[typeName] = { name: typeName }
}
field.type = linkedTypeMap[typeName]!
}
})
})
return linkedTypeMap
}
@@ -0,0 +1,98 @@
// @ts-nocheck
//////////////////////////////////////////////////
// SOME THINGS TO KNOW BEFORE DIVING IN
/*
0. DST is the request type, SRC is the response type
1. FieldsSelection uses an object because currently is impossible to make recursive types
2. FieldsSelection is a recursive type that makes a type based on request type and fields
3. HandleObject handles object types
4. Handle__scalar adds all scalar properties excluding non scalar props
*/
export type FieldsSelection<SRC extends Anify<DST> | undefined, DST> = {
scalar: SRC
union: Handle__isUnion<SRC, DST>
object: HandleObject<SRC, DST>
array: SRC extends Nil
? never
: SRC extends (infer T)[]
? Array<FieldsSelection<T, DST>>
: never
__scalar: Handle__scalar<SRC, DST>
never: never
}[DST extends Nil
? 'never'
: SRC extends Nil
? 'never'
: DST extends false | 0
? 'never'
: SRC extends Scalar
? 'scalar'
: SRC extends any[]
? 'array'
: SRC extends { __isUnion?: any }
? 'union'
: DST extends { __scalar?: any }
? '__scalar'
: DST extends {}
? 'object'
: 'never']
type HandleObject<SRC extends Anify<DST>, DST> = SRC extends Nil
? never
: Pick<
{
// using keyof SRC to maintain ?: relations of SRC type
[Key in keyof SRC]: Key extends keyof DST
? FieldsSelection<
NonNullable<SRC[Key]>,
NonNullable<DST[Key]>
>
: SRC[Key]
},
Exclude<keyof DST, FieldsToRemove>
// {
// // remove falsy values
// [Key in keyof DST]: DST[Key] extends false | 0 ? never : Key
// }[keyof DST]
>
type Handle__scalar<SRC extends Anify<DST>, DST> = SRC extends Nil
? never
: Pick<
// continue processing fields that are in DST, directly pass SRC type if not in DST
{
[Key in keyof SRC]: Key extends keyof DST
? FieldsSelection<SRC[Key], DST[Key]>
: SRC[Key]
},
// remove fields that are not scalars or are not in DST
{
[Key in keyof SRC]: SRC[Key] extends Nil
? never
: Key extends FieldsToRemove
? never
: SRC[Key] extends Scalar
? Key
: Key extends keyof DST
? Key
: never
}[keyof SRC]
>
type Handle__isUnion<SRC extends Anify<DST>, DST> = SRC extends Nil
? never
: Omit<SRC, FieldsToRemove> // just return the union type
type Scalar = string | number | Date | boolean | null | undefined
type Anify<T> = { [P in keyof T]?: any }
type FieldsToRemove = '__isUnion' | '__scalar' | '__name' | '__args'
type Nil = undefined | null
@@ -0,0 +1,69 @@
// @ts-nocheck
export interface ExecutionResult<TData = { [key: string]: any }> {
errors?: Array<Error>
data?: TData | null
}
export interface ArgMap<keyType = number> {
[arg: string]: [keyType, string] | [keyType] | undefined
}
export type CompressedField<keyType = number> = [
type: keyType,
args?: ArgMap<keyType>,
]
export interface CompressedFieldMap<keyType = number> {
[field: string]: CompressedField<keyType> | undefined
}
export type CompressedType<keyType = number> = CompressedFieldMap<keyType>
export interface CompressedTypeMap<keyType = number> {
scalars: Array<keyType>
types: {
[type: string]: CompressedType<keyType> | undefined
}
}
// normal types
export type Field<keyType = number> = {
type: keyType
args?: ArgMap<keyType>
}
export interface FieldMap<keyType = number> {
[field: string]: Field<keyType> | undefined
}
export type Type<keyType = number> = FieldMap<keyType>
export interface TypeMap<keyType = number> {
scalars: Array<keyType>
types: {
[type: string]: Type<keyType> | undefined
}
}
export interface LinkedArgMap {
[arg: string]: [LinkedType, string] | undefined
}
export interface LinkedField {
type: LinkedType
args?: LinkedArgMap
}
export interface LinkedFieldMap {
[field: string]: LinkedField | undefined
}
export interface LinkedType {
name: string
fields?: LinkedFieldMap
scalar?: string[]
}
export interface LinkedTypeMap {
[type: string]: LinkedType | undefined
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
export { CoreApiClient } from './generated/core/index';
export * as CoreSchema from './generated/core/schema';
export { MetadataApiClient } from './generated/metadata/index';
export * as MetadataSchema from './generated/metadata/schema';
+1
View File
@@ -27,6 +27,7 @@ export default defineConfig(() => {
index: 'src/sdk/index.ts',
cli: 'src/cli/cli.ts',
operations: 'src/cli/public-operations/index.ts',
clients: 'src/clients/index.ts',
},
name: 'twenty-sdk',
},
+1 -1
View File
@@ -26,7 +26,7 @@ export default defineConfig({
},
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_TEST_API_KEY:
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
setupFiles: ['src/cli/__tests__/constants/setupTest.ts'],
@@ -20,7 +20,7 @@ export default defineConfig({
fileParallelism: false,
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_TEST_API_KEY:
TWENTY_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
setupFiles: [