[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:
@@ -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
|
||||
|
||||
@@ -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 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`).
|
||||
- `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: {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+4
-7
@@ -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,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
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
dist
|
||||
storybook-static
|
||||
coverage
|
||||
src/clients/generated
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
A CLI and SDK to develop, build, and publish applications that extend [Twenty CRM](https://twenty.com).
|
||||
|
||||
- Two auto‑generated 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)
|
||||
- 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+6
-7
@@ -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(),
|
||||
],
|
||||
},
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+4
-1
@@ -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(() => {
|
||||
|
||||
+1
-1
@@ -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,
|
||||
});
|
||||
|
||||
+4
-12
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+225
@@ -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
@@ -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';
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user