Compare commits

...

5 Commits

Author SHA1 Message Date
prastoin 80e675f430 chore 2026-03-10 13:45:11 +01:00
prastoin 29091ca369 refactor(sdk): core client genql runtime and twenty patch file tree 2026-03-10 11:33:05 +01:00
prastoin 5e71b7cb9c create new tests 2026-03-09 19:14:15 +01:00
prastoin a641fbe804 remove second build 2026-03-09 18:45:27 +01:00
prastoin f536c4d8b3 patch 2026-03-09 18:37:44 +01:00
14 changed files with 1941 additions and 38 deletions
@@ -64,6 +64,78 @@ describe('functionExecute E2E', () => {
});
});
it('should execute a CoreApiClient-based function and return companies', async () => {
const result = await functionExecute({
appPath: APP_PATH,
functionName: 'list-companies',
});
expect(result).toMatchObject({
success: true,
data: { functionName: 'list-companies', status: 'SUCCESS' },
});
const data = (result as { success: true; data: { data: unknown } }).data
.data as { companies: { edges: unknown[] } };
expect(data.companies).toBeDefined();
expect(data.companies.edges).toMatchInlineSnapshot(`
[
{
"node": {
"name": "Housecall Pro",
},
},
{
"node": {
"name": "Odessa",
},
},
{
"node": {
"name": "airSlate",
},
},
]
`);
});
it('should execute a MetadataApiClient-based function and return objects', async () => {
const result = await functionExecute({
appPath: APP_PATH,
functionName: 'list-objects',
});
expect(result).toMatchObject({
success: true,
data: { functionName: 'list-objects', status: 'SUCCESS' },
});
const data = (result as { success: true; data: { data: unknown } }).data
.data as { objects: { edges: unknown[] } };
expect(data.objects).toBeDefined();
expect(data.objects.edges).toMatchInlineSnapshot(`
[
{
"node": {
"nameSingular": "messageChannelMessageAssociation",
},
},
{
"node": {
"nameSingular": "favorite",
},
},
{
"node": {
"nameSingular": "company",
},
},
]
`);
});
it('should return FUNCTION_NOT_FOUND for a non-existent function name', async () => {
const result = await functionExecute({
appPath: APP_PATH,
@@ -0,0 +1,25 @@
import { CoreApiClient } from '@/clients';
import { defineLogicFunction } from '@/sdk';
export const LIST_COMPANIES_UNIVERSAL_IDENTIFIER =
'b3a7e2c1-4f89-4d12-a6e3-9c8b1d0f5e7a';
const listCompaniesHandler = async () => {
const coreClient = new CoreApiClient();
const result = await coreClient.query({
companies: {
__args: { first: 3 },
edges: { node: { name: true } },
},
});
return { companies: result.companies };
};
export default defineLogicFunction({
universalIdentifier: LIST_COMPANIES_UNIVERSAL_IDENTIFIER,
name: 'list-companies',
timeoutSeconds: 10,
handler: listCompaniesHandler,
});
@@ -0,0 +1,25 @@
import { MetadataApiClient } from '@/clients';
import { defineLogicFunction } from '@/sdk';
export const LIST_OBJECTS_UNIVERSAL_IDENTIFIER =
'c4d8f3a2-5e91-4b23-b7f4-0d9c2e1a6f8b';
const listObjectsHandler = async () => {
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
objects: {
__args: { paging: { first: 3 } },
edges: { node: { nameSingular: true } },
},
});
return { objects: result.objects };
};
export default defineLogicFunction({
universalIdentifier: LIST_OBJECTS_UNIVERSAL_IDENTIFIER,
name: 'list-objects',
timeoutSeconds: 10,
handler: listObjectsHandler,
});
@@ -5,7 +5,6 @@ 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';
@@ -54,24 +53,6 @@ const innerAppBuild = async (
filePaths,
});
onProgress?.('Syncing application schema...');
const firstSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: firstBuildResult.builtFileInfos,
});
if (!firstSyncResult.success) {
return firstSyncResult;
}
onProgress?.('Generating API client...');
const clientService = new ClientService();
await clientService.generateCoreClient({ appPath });
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
@@ -91,31 +72,23 @@ const innerAppBuild = async (
};
}
onProgress?.('Rebuilding with generated client...');
onProgress?.('Syncing application...');
const finalBuildResult = await buildApplication({
const syncResult = await synchronizeBuiltApplication({
appPath,
manifest,
filePaths,
builtFileInfos: firstBuildResult.builtFileInfos,
});
onProgress?.('Syncing built files...');
const finalSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: finalBuildResult.builtFileInfos,
});
if (!finalSyncResult.success) {
return finalSyncResult;
if (!syncResult.success) {
return syncResult;
}
const outputDir = path.join(appPath, '.twenty', 'output');
const result: AppBuildResult = {
outputDir,
fileCount: finalBuildResult.builtFileInfos.size,
fileCount: firstBuildResult.builtFileInfos.size,
};
if (options.tarball) {
@@ -0,0 +1,806 @@
import { CoreApiClient } from '../index';
describe('CoreApiClient', () => {
const TEST_API_URL = 'https://api.twenty.com';
let savedEnv: NodeJS.ProcessEnv;
beforeEach(() => {
savedEnv = process.env;
process.env = {
...savedEnv,
TWENTY_API_URL: TEST_API_URL,
};
});
afterEach(() => {
process.env = savedEnv;
});
// The internal fetcher wrapper expects the custom fetcher to return
// the raw JSON response `{ data, errors }`. It then extracts `.data` and
// throws CoreGraphqlError on errors. CoreApiClient's custom fetcher
// returns the full payload from assertResponseIsSuccessful.
const createMockFetch = (
responseData: Record<string, unknown>,
statusOverrides?: { status?: number; statusText?: string },
) => {
return vi.fn().mockResolvedValue({
status: statusOverrides?.status ?? 200,
statusText: statusOverrides?.statusText ?? 'OK',
text: () =>
Promise.resolve(JSON.stringify({ data: responseData })),
});
};
const parseSentBody = (mockFetch: ReturnType<typeof vi.fn>) => {
return JSON.parse(mockFetch.mock.calls[0]![1].body) as {
query: string;
variables: Record<string, unknown>;
operationName?: string;
};
};
const getSentHeaders = (
mockFetch: ReturnType<typeof vi.fn>,
callIndex = 0,
) => {
return new Headers(mockFetch.mock.calls[callIndex]![1].headers);
};
const createClient = (
mockFetch: ReturnType<typeof vi.fn>,
options?: Partial<ConstructorParameters<typeof CoreApiClient>[0]>,
) => {
return new CoreApiClient({
fetch: mockFetch,
...options,
});
};
describe('query', () => {
it('should produce a valid findMany query with pagination', async () => {
const mockFetch = createMockFetch({ companies: { edges: [] } });
const client = createClient(mockFetch);
await client.query({
companies: {
__args: { first: 20, after: 'cursor-xyz' },
edges: { node: { id: true, name: true } },
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toBe(
'query ($v1:Int,$v2:String){companies(first:$v1,after:$v2){edges{node{id,name}}}}',
);
expect(body.variables).toEqual({ v1: 20, v2: 'cursor-xyz' });
});
it('should produce a valid findMany query with filter and orderBy', async () => {
const mockFetch = createMockFetch({ companies: { edges: [] } });
const client = createClient(mockFetch);
await client.query({
companies: {
__args: {
filter: { name: { eq: 'Acme' } },
orderBy: [{ name: 'AscNullsFirst' }],
first: 10,
},
edges: { node: { id: true } },
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toContain('$v1:CompaniesFilterInput');
expect(body.query).toContain('$v2:[CompaniesOrderByInput]');
expect(body.query).toContain('$v3:Int');
expect(body.variables).toEqual({
v1: { name: { eq: 'Acme' } },
v2: [{ name: 'AscNullsFirst' }],
v3: 10,
});
});
it('should produce a valid findOne query', async () => {
const mockFetch = createMockFetch({ company: { id: '1' } });
const client = createClient(mockFetch);
await client.query({
company: {
__args: { filter: { id: { eq: 'abc-123' } } },
id: true,
name: true,
createdAt: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toBe(
'query ($v1:CompanyFilterInput){company(filter:$v1){id,name,createdAt}}',
);
expect(body.variables).toEqual({
v1: { id: { eq: 'abc-123' } },
});
});
it('should produce a valid query without args', async () => {
const mockFetch = createMockFetch({ companies: { edges: [] } });
const client = createClient(mockFetch);
await client.query({
companies: {
edges: { node: { id: true, name: true } },
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toBe(
'query {companies{edges{node{id,name}}}}',
);
expect(body.variables).toEqual({});
});
});
describe('mutation', () => {
it('should produce a valid createOne mutation', async () => {
const mockFetch = createMockFetch({
createCompany: { id: 'new-id' },
});
const client = createClient(mockFetch);
await client.mutation({
createCompany: {
__args: { data: { name: 'Acme', employees: 50 } },
id: true,
name: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toBe(
'mutation ($v1:CompanyCreateInput!){createCompany(data:$v1){id,name}}',
);
expect(body.variables).toEqual({
v1: { name: 'Acme', employees: 50 },
});
});
it('should produce a valid updateOne mutation', async () => {
const mockFetch = createMockFetch({
updateCompany: { id: 'abc-123' },
});
const client = createClient(mockFetch);
await client.mutation({
updateCompany: {
__args: {
id: 'abc-123',
data: { name: 'Updated Corp' },
},
id: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toBe(
'mutation ($v1:UUID!,$v2:CompanyUpdateInput!){updateCompany(id:$v1,data:$v2){id}}',
);
expect(body.variables).toEqual({
v1: 'abc-123',
v2: { name: 'Updated Corp' },
});
});
it('should produce a valid deleteOne mutation', async () => {
const mockFetch = createMockFetch({
deleteCompany: { id: 'abc-123' },
});
const client = createClient(mockFetch);
await client.mutation({
deleteCompany: {
__args: { id: 'abc-123' },
id: true,
deletedAt: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toBe(
'mutation ($v1:UUID!){deleteCompany(id:$v1){id,deletedAt}}',
);
expect(body.variables).toEqual({ v1: 'abc-123' });
});
it('should produce a valid destroyOne mutation', async () => {
const mockFetch = createMockFetch({
destroyPerson: { id: 'p-1' },
});
const client = createClient(mockFetch);
await client.mutation({
destroyPerson: {
__args: { id: 'p-1' },
id: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toBe(
'mutation ($v1:UUID!){destroyPerson(id:$v1){id}}',
);
});
it('should produce a valid restoreOne mutation', async () => {
const mockFetch = createMockFetch({
restoreCompany: { id: 'abc-123' },
});
const client = createClient(mockFetch);
await client.mutation({
restoreCompany: {
__args: { id: 'abc-123' },
id: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toBe(
'mutation ($v1:UUID!){restoreCompany(id:$v1){id}}',
);
});
it('should produce a valid updateMany mutation', async () => {
const mockFetch = createMockFetch({
updateCompanies: [{ id: '1' }],
});
const client = createClient(mockFetch);
await client.mutation({
updateCompanies: {
__args: {
data: { name: 'Bulk Updated' },
filter: { name: { eq: 'Old Name' } },
},
id: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toContain('$v1:CompaniesUpdateInput!');
expect(body.query).toContain('$v2:CompaniesFilterInput!');
});
it('should produce a valid deleteMany mutation', async () => {
const mockFetch = createMockFetch({
deleteCompanies: [{ id: '1' }],
});
const client = createClient(mockFetch);
await client.mutation({
deleteCompanies: {
__args: { filter: { name: { eq: 'test' } } },
id: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toContain('$v1:CompaniesFilterInput!');
});
it('should produce a valid mergeMany mutation', async () => {
const mockFetch = createMockFetch({
mergeCompanies: { id: 'merged' },
});
const client = createClient(mockFetch);
await client.mutation({
mergeCompanies: {
__args: {
ids: ['id-1', 'id-2'],
conflictPriorityIndex: 0,
dryRun: true,
},
id: true,
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toContain('$v1:[UUID!]!');
expect(body.query).toContain('$v2:Int!');
expect(body.query).toContain('$v3:Boolean');
expect(body.variables).toEqual({
v1: ['id-1', 'id-2'],
v2: 0,
v3: true,
});
});
it('should handle nested field selections in mutations', async () => {
const mockFetch = createMockFetch({
createCompany: {
id: 'new-id',
people: { edges: [{ node: { id: 'p-1' } }] },
},
});
const client = createClient(mockFetch);
await client.mutation({
createCompany: {
__args: { data: { name: 'Acme' } },
id: true,
people: {
edges: {
node: {
id: true,
name: { firstName: true, lastName: true },
},
},
},
},
});
const body = parseSentBody(mockFetch);
expect(body.query).toContain(
'people{edges{node{id,name{firstName,lastName}}}}',
);
});
});
describe('request configuration', () => {
it('should default URL to TWENTY_API_URL/graphql from env', async () => {
const mockFetch = createMockFetch({});
const client = createClient(mockFetch);
await client.query({ companies: { edges: { node: { id: true } } } });
expect(mockFetch.mock.calls[0]![0]).toBe(
`${TEST_API_URL}/graphql`,
);
});
it('should allow overriding the URL', async () => {
const mockFetch = createMockFetch({});
const client = createClient(mockFetch, {
url: 'https://custom.api.com/gql',
});
await client.query({ companies: { edges: { node: { id: true } } } });
expect(mockFetch.mock.calls[0]![0]).toBe(
'https://custom.api.com/gql',
);
});
it('should use POST method', async () => {
const mockFetch = createMockFetch({});
const client = createClient(mockFetch);
await client.query({ companies: { edges: { node: { id: true } } } });
expect(mockFetch.mock.calls[0]![1].method).toBe('POST');
});
it('should set Content-Type to application/json', async () => {
const mockFetch = createMockFetch({});
const client = createClient(mockFetch);
await client.query({ companies: { edges: { node: { id: true } } } });
const headers = getSentHeaders(mockFetch);
expect(headers.get('Content-Type')).toBe('application/json');
});
});
describe('authentication', () => {
it('should set Authorization header from explicit Bearer token', async () => {
const mockFetch = createMockFetch({});
const client = createClient(mockFetch, {
headers: { Authorization: 'Bearer my-token' },
});
await client.query({ companies: { edges: { node: { id: true } } } });
const headers = getSentHeaders(mockFetch);
expect(headers.get('Authorization')).toBe('Bearer my-token');
});
it('should pick up token from TWENTY_APP_ACCESS_TOKEN env var', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'env-app-token';
const mockFetch = createMockFetch({});
const client = createClient(mockFetch);
await client.query({
companies: { edges: { node: { id: true } } },
});
const headers = getSentHeaders(mockFetch);
expect(headers.get('Authorization')).toBe('Bearer env-app-token');
});
it('should pick up token from TWENTY_API_KEY env var as fallback', async () => {
delete process.env.TWENTY_APP_ACCESS_TOKEN;
process.env.TWENTY_API_KEY = 'env-api-key';
const mockFetch = createMockFetch({});
const client = createClient(mockFetch);
await client.query({
companies: { edges: { node: { id: true } } },
});
const headers = getSentHeaders(mockFetch);
expect(headers.get('Authorization')).toBe('Bearer env-api-key');
});
it('should prioritize explicit header over env vars', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'env-token';
const mockFetch = createMockFetch({});
const client = createClient(mockFetch, {
headers: { Authorization: 'Bearer explicit-token' },
});
await client.query({
companies: { edges: { node: { id: true } } },
});
const headers = getSentHeaders(mockFetch);
expect(headers.get('Authorization')).toBe(
'Bearer explicit-token',
);
});
it('should not set Authorization header when no token is available', async () => {
delete process.env.TWENTY_APP_ACCESS_TOKEN;
delete process.env.TWENTY_API_KEY;
const mockFetch = createMockFetch({});
const client = createClient(mockFetch);
await client.query({
companies: { edges: { node: { id: true } } },
});
const headers = getSentHeaders(mockFetch);
expect(headers.get('Authorization')).toBeNull();
});
});
describe('token refresh', () => {
afterEach(() => {
delete (globalThis as Record<string, unknown>)
.frontComponentHostCommunicationApi;
});
it('should retry with refreshed token on 401 response', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
status: 401,
statusText: 'Unauthorized',
text: () =>
Promise.resolve(JSON.stringify({ errors: [] })),
})
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
text: () =>
Promise.resolve(
JSON.stringify({ data: { company: { id: '1' } } }),
),
});
const mockRefresh = vi.fn().mockResolvedValue('refreshed-token');
(globalThis as Record<string, unknown>)
.frontComponentHostCommunicationApi = {
requestAccessTokenRefresh: mockRefresh,
};
process.env.TWENTY_APP_ACCESS_TOKEN = 'expired';
const client = createClient(mockFetch);
const result = await client.query({
company: {
__args: { filter: { id: { eq: 'abc' } } },
id: true,
},
});
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockRefresh).toHaveBeenCalledTimes(1);
const retryHeaders = getSentHeaders(mockFetch, 1);
expect(retryHeaders.get('Authorization')).toBe(
'Bearer refreshed-token',
);
// genql runtime unwraps .data from the payload
expect(result).toEqual({ company: { id: '1' } });
});
it('should retry on UNAUTHENTICATED GraphQL error', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
text: () =>
Promise.resolve(
JSON.stringify({
errors: [
{ extensions: { code: 'UNAUTHENTICATED' } },
],
}),
),
})
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
text: () =>
Promise.resolve(
JSON.stringify({ data: { companies: [] } }),
),
});
(globalThis as Record<string, unknown>)
.frontComponentHostCommunicationApi = {
requestAccessTokenRefresh: vi
.fn()
.mockResolvedValue('new-token'),
};
const client = createClient(mockFetch);
await client.query({
companies: { edges: { node: { id: true } } },
});
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should not retry when no refresh function is available', async () => {
const mockFetch = vi.fn().mockResolvedValue({
status: 401,
statusText: 'Unauthorized',
text: () =>
Promise.resolve(JSON.stringify({ errors: [] })),
});
const client = createClient(mockFetch);
await expect(
client.query({
companies: { edges: { node: { id: true } } },
}),
).rejects.toThrow('Unauthorized');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should not retry when refresh returns empty string', async () => {
const mockFetch = vi.fn().mockResolvedValue({
status: 401,
statusText: 'Unauthorized',
text: () =>
Promise.resolve(JSON.stringify({ errors: [] })),
});
(globalThis as Record<string, unknown>)
.frontComponentHostCommunicationApi = {
requestAccessTokenRefresh: vi.fn().mockResolvedValue(''),
};
const client = createClient(mockFetch);
await expect(
client.query({
companies: { edges: { node: { id: true } } },
}),
).rejects.toThrow('Unauthorized');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should not retry when refresh throws', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockFetch = vi.fn().mockResolvedValue({
status: 401,
statusText: 'Unauthorized',
text: () =>
Promise.resolve(JSON.stringify({ errors: [] })),
});
(globalThis as Record<string, unknown>)
.frontComponentHostCommunicationApi = {
requestAccessTokenRefresh: vi
.fn()
.mockRejectedValue(new Error('refresh failed')),
};
const client = createClient(mockFetch);
await expect(
client.query({
companies: { edges: { node: { id: true } } },
}),
).rejects.toThrow('Unauthorized');
expect(mockFetch).toHaveBeenCalledTimes(1);
consoleSpy.mockRestore();
});
});
describe('response handling', () => {
it('should return unwrapped data on success', async () => {
const mockFetch = createMockFetch({
createCompany: { id: 'new-id', name: 'Acme' },
});
const client = createClient(mockFetch);
const result = await client.mutation({
createCompany: {
__args: { data: { name: 'Acme' } },
id: true,
name: true,
},
});
// genql runtime unwraps .data from the payload
expect(result).toEqual({
createCompany: { id: 'new-id', name: 'Acme' },
});
});
it('should throw on non-2xx response with status text and body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
status: 500,
statusText: 'Internal Server Error',
text: () => Promise.resolve('{"errors":[{"message":"boom"}]}'),
});
const client = createClient(mockFetch);
await expect(
client.mutation({
createCompany: {
__args: { data: { name: 'Acme' } },
id: true,
},
}),
).rejects.toThrow('Internal Server Error');
});
it('should throw on empty response body', async () => {
const mockFetch = vi.fn().mockResolvedValue({
status: 200,
statusText: 'OK',
text: () => Promise.resolve(''),
});
const client = createClient(mockFetch);
await expect(
client.query({
companies: { edges: { node: { id: true } } },
}),
).rejects.toThrow('Invalid JSON response');
});
it('should throw on malformed JSON response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
status: 200,
statusText: 'OK',
text: () => Promise.resolve('not json'),
});
const client = createClient(mockFetch);
await expect(
client.query({
companies: { edges: { node: { id: true } } },
}),
).rejects.toThrow('Invalid JSON response');
});
});
describe('error cases', () => {
it('should throw when fetch is not available', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = undefined as unknown as typeof fetch;
try {
const client = new CoreApiClient();
await expect(
client.query({
companies: { edges: { node: { id: true } } },
}),
).rejects.toThrow('fetch');
} finally {
globalThis.fetch = originalFetch;
}
});
it('should throw for unrecognized argument names', async () => {
const mockFetch = createMockFetch({});
const client = createClient(mockFetch);
await expect(
client.mutation({
createCompany: {
__args: { unknownArg: 'value' },
id: true,
},
}),
).rejects.toThrow(/Cannot infer GraphQL type/);
});
});
describe('headers resolution', () => {
it('should support static headers object', async () => {
const mockFetch = createMockFetch({});
const client = createClient(mockFetch, {
headers: {
'X-Custom': 'custom-value',
Authorization: 'Bearer static',
},
});
await client.query({
companies: { edges: { node: { id: true } } },
});
const headers = getSentHeaders(mockFetch);
expect(headers.get('X-Custom')).toBe('custom-value');
expect(headers.get('Authorization')).toBe('Bearer static');
});
it('should support async headers function', async () => {
const mockFetch = createMockFetch({});
const client = createClient(mockFetch, {
headers: async () => ({
'X-Dynamic': 'dynamic-value',
}),
});
await client.query({
companies: { edges: { node: { id: true } } },
});
const headers = getSentHeaders(mockFetch);
expect(headers.get('X-Dynamic')).toBe('dynamic-value');
});
});
});
@@ -1,2 +1,488 @@
// Stub — overwritten by `twenty app:build` or `twenty app:dev`
export class CoreApiClient {}
// @ts-nocheck
// Core API client stub — fully self-contained, no genql dependency.
// Uses convention-based type inference so logic functions work
// without code generation. Overwritten by `twenty app:dev` for
// full type safety during local development.
import {
createClient as createClientOriginal,
generateGraphqlOperation,
CoreGraphqlError,
type FieldsSelection,
type GraphqlOperation,
type ClientOptions,
} from './twenty-patches';
export type { FieldsSelection } from './twenty-patches';
export { CoreGraphqlError };
// Empty roots — the type map is not used in stub mode.
// Convention-based inference in generateGraphqlOperation handles
// argument type resolution instead.
const EMPTY_ROOT = { name: '', fields: {}, scalar: [] };
export type Query = {};
export type QueryGenqlSelection = {};
export type Mutation = {};
export type MutationGenqlSelection = {};
export type Subscription = {};
export type SubscriptionGenqlSelection = {};
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: EMPTY_ROOT,
mutationRoot: EMPTY_ROOT,
subscriptionRoot: EMPTY_ROOT,
}) 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', EMPTY_ROOT, 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', EMPTY_ROOT, 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',
EMPTY_ROOT,
fields as any,
);
};
// CoreApiClient (auto-injected by twenty-sdk)
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
type CoreApiClientOptions = 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 getDefaultUrl = (): string => {
const processEnvironment = getProcessEnvironment();
return `${processEnvironment.TWENTY_API_URL ?? ''}/graphql`;
};
export class CoreApiClient {
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?: CoreApiClientOptions) {
const merged: CoreApiClientOptions = {
url: getDefaultUrl(),
headers: {
'Content-Type': 'application/json',
},
...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,
);
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 CoreGraphqlError(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;
}
}
@@ -1,2 +0,0 @@
// Stub — overwritten by `twenty app:build` or `twenty app:dev`
export type CoreSchema = {};
@@ -0,0 +1,69 @@
// @ts-nocheck
// Creates a lightweight GraphQL client that uses convention-based
// type inference. No genql dependency.
import type { LinkedType, ClientOptions, GraphqlOperation } from './types';
import { CoreGraphqlError } from './graphql-error';
import { generateGraphqlOperation } from './generate-graphql-operation';
const createFetcher = (options: ClientOptions) => {
const { fetcher: customFetcher } = options;
if (!customFetcher) {
throw new Error(
'CoreApiClient requires a custom fetcher. ' +
'Direct URL-based fetching is not supported in stub mode.',
);
}
return async (operation: GraphqlOperation) => {
const json = await customFetcher(operation);
if (json?.errors?.length) {
throw new CoreGraphqlError(json.errors, json.data);
}
return json.data;
};
};
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) => {
try {
return fetcher(
generateGraphqlOperation('query', queryRoot, request),
);
} catch (error) {
return Promise.reject(error);
}
};
}
if (mutationRoot) {
client.mutation = (request: any) => {
try {
return fetcher(
generateGraphqlOperation('mutation', mutationRoot, request),
);
} catch (error) {
return Promise.reject(error);
}
};
}
return client as any;
};
@@ -0,0 +1,240 @@
// @ts-nocheck
// Generates GraphQL operation strings from a declarative request object.
// Uses convention-based type inference for argument types so the core
// client works without a genql-generated type map.
import type {
LinkedField,
LinkedType,
GraphqlOperation,
} from './types';
import { inferArgType } from './twenty-arg-type-inference';
type Request = boolean | number | Fields;
type Fields = {
[field: string]: Request;
};
type Variables = {
[name: string]: {
value: unknown;
typing: [LinkedType, string];
};
};
type Context = {
root: LinkedType;
varCounter: number;
variables: Variables;
fragmentCounter: number;
fragments: string[];
};
const getFieldFromPath = (
root: LinkedType | undefined,
path: string[],
): LinkedField | undefined => {
let current: LinkedField | undefined;
if (!root) return undefined;
if (path.length === 0) return undefined;
for (const fieldName of path) {
const type = current ? current.type : root;
if (!type?.fields) return undefined;
const possibleTypes = Object.keys(type.fields)
.filter((key) => key.startsWith('on_'))
.reduce(
(types, key) => {
const field = type.fields && type.fields[key];
if (field) types.push(field.type);
return types;
},
[type],
);
let field: LinkedField | null = null;
for (const possibleType of possibleTypes) {
const found = possibleType.fields && possibleType.fields[fieldName];
if (found) {
field = found;
break;
}
}
if (!field) return undefined;
current = field;
}
return current;
};
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];
if (typing) {
ctx.variables[varName] = {
value: args[argName],
typing,
};
} else {
const operationName = path[0] || '';
const inferredType = inferArgType(operationName, argName);
ctx.variables[varName] = {
value: args[argName],
typing: [{ name: inferredType }, inferredType],
};
}
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((key) =>
Boolean(fields[key]),
);
if (fieldNames.length === 0) {
throw new Error(
`field selection should not be empty: ${path.join('.')}`,
);
}
const field =
path.length > 0 ? getFieldFromPath(ctx.root, path) : null;
const type = field ? field.type : ctx.root;
const scalarFields = type?.scalar;
let scalarFieldsFragment: string | undefined;
if (fieldNames.includes('__scalar')) {
const falsyFieldNames = new Set(
Object.keys(fields).filter((key) => !Boolean(fields[key])),
);
if (scalarFields?.length) {
ctx.fragmentCounter++;
scalarFieldsFragment = `f${ctx.fragmentCounter}`;
ctx.fragments.push(
`fragment ${scalarFieldsFragment} on ${
type.name
}{${scalarFields
.filter((scalarField) => !falsyFieldNames.has(scalarField))
.join(',')}}`,
);
}
}
const fieldsSelection = fieldNames
.filter(
(fieldName) => !['__scalar', '__name'].includes(fieldName),
)
.map((fieldName) => {
const parsed = parseRequest(fields[fieldName], ctx, [
...path,
fieldName,
]);
if (fieldName.startsWith('on_')) {
ctx.fragmentCounter++;
const implementationFragment = `f${ctx.fragmentCounter}`;
const typeMatch = fieldName.match(/^on_(.+)/);
if (!typeMatch || !typeMatch[1])
throw new Error('match failed');
ctx.fragments.push(
`fragment ${implementationFragment} on ${typeMatch[1]}${parsed}`,
);
return `...${implementationFragment}`;
} else {
return `${fieldName}${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((varName) => {
const variableType = ctx.variables[varName].typing[1];
return `$${varName}:${variableType}`;
})})`
: '';
const operationName = fields?.__name || '';
return {
query: [
`${operation} ${operationName}${varsString}${result}`,
...ctx.fragments,
].join(','),
variables: Object.keys(ctx.variables).reduce<{
[name: string]: unknown;
}>(
(result, varName) => {
result[varName] = ctx.variables[varName].value;
return result;
},
{},
),
...(operationName
? { operationName: operationName.toString() }
: {}),
};
};
@@ -0,0 +1,23 @@
// Lightweight replacement for GenqlError — no genql dependency.
type GraphqlErrorEntry = {
message?: string;
locations?: Array<{ line: number; column: number }>;
path?: string[];
extensions?: Record<string, unknown>;
};
export class CoreGraphqlError extends Error {
errors: GraphqlErrorEntry[];
data?: unknown;
constructor(errors: GraphqlErrorEntry[], data?: unknown) {
const message =
errors.map((entry) => entry?.message || '').join('\n') ||
'GraphQL error';
super(message);
this.errors = errors;
this.data = data;
}
}
@@ -0,0 +1,13 @@
// @ts-nocheck
// Twenty core client internals — fully self-contained, no genql dependency.
export { generateGraphqlOperation } from './generate-graphql-operation';
export { createClient } from './create-client';
export { CoreGraphqlError } from './graphql-error';
export type { GraphqlOperation, ClientOptions } from './types';
// FieldsSelection is a no-op identity type in stub mode (no real type map).
// When genql codegen runs during app:dev, the generated index.ts replaces
// this entire file with real typed selections.
export type FieldsSelection<_TSource, _TFields> = _TSource;
@@ -0,0 +1,136 @@
// Twenty-specific convention-based GraphQL argument type inference.
// Derives GraphQL variable types from resolver naming patterns
// (e.g. createCompany → CompanyCreateInput!) so the core client
// works without a genql-generated type map.
type ResolverPattern = {
regex: RegExp;
getArgType: (objectName: string, argName: string) => string | null;
};
const RESOLVER_PATTERNS: ResolverPattern[] = [
{
regex: /^create(.+)$/,
getArgType: (objectName, argName) => {
if (argName === 'data') return `${objectName}CreateInput!`;
if (argName === 'upsert') return 'Boolean';
return null;
},
},
{
regex: /^update(.+)$/,
getArgType: (objectName, argName) => {
if (argName === 'id') return 'UUID!';
if (argName === 'data') return `${objectName}UpdateInput!`;
if (argName === 'filter') return `${objectName}FilterInput!`;
return null;
},
},
{
regex: /^delete(.+)$/,
getArgType: (objectName, argName) => {
if (argName === 'id') return 'UUID!';
if (argName === 'filter') return `${objectName}FilterInput!`;
return null;
},
},
{
regex: /^destroy(.+)$/,
getArgType: (objectName, argName) => {
if (argName === 'id') return 'UUID!';
if (argName === 'filter') return `${objectName}FilterInput!`;
return null;
},
},
{
regex: /^restore(.+)$/,
getArgType: (objectName, argName) => {
if (argName === 'id') return 'UUID!';
if (argName === 'filter') return `${objectName}FilterInput!`;
return null;
},
},
{
regex: /^merge(.+)$/,
getArgType: (_objectName, argName) => {
if (argName === 'ids') return '[UUID!]!';
if (argName === 'conflictPriorityIndex') return 'Int!';
if (argName === 'dryRun') return 'Boolean';
return null;
},
},
];
const QUERY_COMMON_ARG_TYPES: Record<string, string> = {
first: 'Int',
last: 'Int',
offset: 'Int',
offsetForRecords: 'Int',
limit: 'Int',
before: 'String',
after: 'String',
id: 'UUID!',
ids: '[UUID]',
upsert: 'Boolean',
dryRun: 'Boolean',
conflictPriorityIndex: 'Int!',
viewId: 'UUID',
};
const inferArgTypeForUnprefixedResolver = (
resolverName: string,
argName: string,
): string | null => {
const commonType = QUERY_COMMON_ARG_TYPES[argName];
if (commonType) return commonType;
let objectName = resolverName;
if (resolverName.endsWith('Duplicates')) {
objectName = resolverName.slice(0, -'Duplicates'.length);
} else if (resolverName.endsWith('GroupBy')) {
objectName = resolverName.slice(0, -'GroupBy'.length);
}
const pascalObjectName =
objectName.charAt(0).toUpperCase() + objectName.slice(1);
if (argName === 'filter') return `${pascalObjectName}FilterInput`;
if (argName === 'orderBy') return `[${pascalObjectName}OrderByInput]`;
if (argName === 'orderByForRecords')
return `[${pascalObjectName}OrderByInput]`;
if (argName === 'groupBy') return `[${pascalObjectName}GroupByInput!]!`;
if (argName === 'data') return `[${pascalObjectName}CreateInput!]`;
return null;
};
export const inferArgType = (
operationName: string,
argName: string,
): string => {
for (const pattern of RESOLVER_PATTERNS) {
const match = operationName.match(pattern.regex);
if (!match) continue;
const objectName = match[1]!;
const resolved = pattern.getArgType(objectName, argName);
if (resolved) return resolved;
}
const unprefixedResult = inferArgTypeForUnprefixedResolver(
operationName,
argName,
);
if (unprefixedResult) return unprefixedResult;
throw new Error(
`Cannot infer GraphQL type for argument '${argName}' ` +
`on operation '${operationName}'. ` +
`Run 'twenty app:build' to generate a typed client.`,
);
};
@@ -0,0 +1,38 @@
// Minimal type definitions for the core client stub.
// These mirror the shapes used by the genql runtime but are
// self-contained — no imports from @genql/runtime.
export type LinkedArgMap = {
[arg: string]: [LinkedType, string] | undefined;
};
export type LinkedField = {
type: LinkedType;
args?: LinkedArgMap;
};
export type LinkedFieldMap = {
[field: string]: LinkedField | undefined;
};
export type LinkedType = {
name: string;
fields?: LinkedFieldMap;
scalar?: string[];
};
export type GraphqlOperation = {
query: string;
variables?: { [name: string]: unknown };
operationName?: string;
};
export type ClientOptions = Omit<RequestInit, 'body' | 'headers'> & {
url?: string;
batch?: unknown;
fetcher?: (
operation: GraphqlOperation | GraphqlOperation[],
) => Promise<unknown>;
fetch?: typeof globalThis.fetch;
headers?: HeadersInit | (() => HeadersInit) | (() => Promise<HeadersInit>);
};
-1
View File
@@ -1,4 +1,3 @@
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';